umangchaudhry commited on
Commit
64b6fa7
·
verified ·
1 Parent(s): 8b8d92b

Upload 11 files

Browse files
Files changed (11) hide show
  1. Dockerfile +24 -8
  2. app.py +1646 -0
  3. app_config.json +9 -0
  4. config_manager.py +789 -0
  5. dashboard.py +780 -0
  6. google_drive_manager.py +332 -0
  7. guests.py +1602 -0
  8. requirements.txt +12 -3
  9. tasks.py +665 -0
  10. vendors.py +0 -0
  11. wedding_party.py +799 -0
Dockerfile CHANGED
@@ -1,20 +1,36 @@
1
- FROM python:3.13.5-slim
 
2
 
 
3
  WORKDIR /app
4
 
 
 
 
 
 
5
  RUN apt-get update && apt-get install -y \
6
- build-essential \
7
- curl \
8
- git \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
- COPY requirements.txt ./
12
- COPY src/ ./src/
 
 
 
 
 
 
13
 
14
- RUN pip3 install -r requirements.txt
 
15
 
 
16
  EXPOSE 8501
17
 
 
18
  HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
19
 
20
- ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
 
 
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,1646 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import json
3
+ import os
4
+ import yaml
5
+ from datetime import datetime, date, timedelta
6
+ import pandas as pd
7
+ from yaml.loader import SafeLoader
8
+ import streamlit_authenticator as stauth
9
+ from config_manager import ConfigManager
10
+ from dashboard import Dashboard
11
+ from tasks import TasksManager
12
+ from guests import GuestManager
13
+ from wedding_party import WeddingPartyManager
14
+ from vendors import VendorManager
15
+
16
+ # Page configuration
17
+ st.set_page_config(
18
+ page_title="Wedding Planner",
19
+ page_icon="💒",
20
+ layout="wide",
21
+ initial_sidebar_state="expanded"
22
+ )
23
+
24
+ # Custom CSS for green/Adirondack theme
25
+ st.markdown("""
26
+ <style>
27
+ .main-header {
28
+ background: linear-gradient(90deg, #2d5016, #4a7c59);
29
+ color: white;
30
+ padding: 1rem;
31
+ border-radius: 10px;
32
+ text-align: center;
33
+ margin-bottom: 2rem;
34
+ }
35
+
36
+ .main-header h1, .main-header h2, .main-header h3, .main-header p {
37
+ color: white !important;
38
+ }
39
+
40
+ .main-header * {
41
+ color: white !important;
42
+ }
43
+
44
+ /* Ensure green background elements always have white text regardless of theme */
45
+ .main-header, .main-header * {
46
+ color: white !important;
47
+ }
48
+
49
+ .metric-card, .metric-card * {
50
+ color: white !important;
51
+ }
52
+
53
+ .metric-card {
54
+ background: linear-gradient(135deg, #4a7c59, #6b8e6b) !important;
55
+ color: white !important;
56
+ padding: 1rem;
57
+ border-radius: 10px;
58
+ text-align: center;
59
+ margin: 0.5rem;
60
+ height: 150px;
61
+ display: flex;
62
+ flex-direction: column;
63
+ justify-content: center;
64
+ align-items: center;
65
+ }
66
+
67
+ .metric-card h1, .metric-card h2, .metric-card h3, .metric-card p {
68
+ color: white !important;
69
+ background: transparent !important;
70
+ }
71
+
72
+ .metric-card * {
73
+ color: white !important;
74
+ background: transparent !important;
75
+ }
76
+
77
+ /* Override Streamlit's default styling for elements inside metric cards */
78
+ .metric-card .stMarkdown {
79
+ background: transparent !important;
80
+ }
81
+
82
+ .metric-card .stMarkdown * {
83
+ color: white !important;
84
+ background: transparent !important;
85
+ }
86
+
87
+ /* Additional overrides for Streamlit elements */
88
+ div[data-testid="stMarkdownContainer"] {
89
+ background: transparent !important;
90
+ }
91
+
92
+ .metric-card div[data-testid="stMarkdownContainer"] {
93
+ background: transparent !important;
94
+ }
95
+
96
+ .metric-card div[data-testid="stMarkdownContainer"] * {
97
+ color: white !important;
98
+ background: transparent !important;
99
+ }
100
+
101
+ /* Fix text color for dashboard metrics below cards - use dark text for better visibility */
102
+ .main .block-container h1,
103
+ .main .block-container h2,
104
+ .main .block-container h3,
105
+ .main .block-container h4,
106
+ .main .block-container h5,
107
+ .main .block-container h6 {
108
+ color: #262730 !important;
109
+ }
110
+
111
+ .main .block-container p,
112
+ .main .block-container div,
113
+ .main .block-container span {
114
+ color: #262730 !important;
115
+ }
116
+
117
+ /* Specific styling for dashboard content */
118
+ .main .block-container .stMarkdown {
119
+ color: #262730 !important;
120
+ }
121
+
122
+ .main .block-container .stMarkdown * {
123
+ color: #262730 !important;
124
+ }
125
+
126
+ /* Override for dark mode */
127
+ @media (prefers-color-scheme: dark) {
128
+ .main .block-container h1,
129
+ .main .block-container h2,
130
+ .main .block-container h3,
131
+ .main .block-container h4,
132
+ .main .block-container h5,
133
+ .main .block-container h6 {
134
+ color: #fafafa !important;
135
+ }
136
+
137
+ .main .block-container p,
138
+ .main .block-container div,
139
+ .main .block-container span {
140
+ color: #fafafa !important;
141
+ }
142
+
143
+ .main .block-container .stMarkdown {
144
+ color: #fafafa !important;
145
+ }
146
+
147
+ .main .block-container .stMarkdown * {
148
+ color: #fafafa !important;
149
+ }
150
+
151
+ /* But keep green background elements white even in dark mode */
152
+ .main-header, .main-header * {
153
+ color: white !important;
154
+ }
155
+
156
+ .metric-card, .metric-card * {
157
+ color: white !important;
158
+ }
159
+ }
160
+
161
+ .sidebar .sidebar-content {
162
+ background: linear-gradient(180deg, #2d5016, #4a7c59);
163
+ }
164
+
165
+ .stButton > button {
166
+ background: linear-gradient(90deg, #4a7c59, #6b8e6b);
167
+ color: white;
168
+ border: none;
169
+ border-radius: 5px;
170
+ padding: 0.5rem 1rem;
171
+ }
172
+
173
+ .stButton > button:hover {
174
+ background: linear-gradient(90deg, #2d5016, #4a7c59);
175
+ color: white;
176
+ }
177
+
178
+ .task-card {
179
+ background: #f8f9fa;
180
+ border-left: 4px solid #4a7c59;
181
+ padding: 1rem;
182
+ margin: 0.5rem 0;
183
+ border-radius: 5px;
184
+ }
185
+
186
+ .guest-card {
187
+ background: #f8f9fa;
188
+ border: 1px solid #4a7c59;
189
+ padding: 1rem;
190
+ margin: 0.5rem 0;
191
+ border-radius: 5px;
192
+ }
193
+ </style>
194
+ """, unsafe_allow_html=True)
195
+
196
+ def load_auth_config():
197
+ """Load authentication configuration from Google Drive"""
198
+ try:
199
+ config_manager = st.session_state.config_manager
200
+
201
+ # Try to load config.yaml from Google Drive root
202
+ if config_manager.google_drive_enabled:
203
+ config_content = config_manager.drive_manager.download_file('config.yaml')
204
+ if config_content:
205
+ # Parse YAML content
206
+ config = yaml.load(config_content, Loader=SafeLoader)
207
+ return config
208
+
209
+ # Fallback to local config.yaml if Google Drive fails
210
+ if os.path.exists('config.yaml'):
211
+ with open('config.yaml') as file:
212
+ config = yaml.load(file, Loader=SafeLoader)
213
+ return config
214
+
215
+ st.error("❌ No auth config found")
216
+ return None
217
+ except Exception as e:
218
+ st.error(f"Error loading authentication config: {e}")
219
+ return None
220
+
221
+ def get_user_folder_from_username(username):
222
+ """Get the user folder based on username using wedding mappings"""
223
+ try:
224
+ # Load auth config to get wedding mappings
225
+ auth_config = st.session_state.get('auth_config')
226
+ if not auth_config:
227
+ # Fallback to loading config directly
228
+ auth_config = load_auth_config()
229
+
230
+ if auth_config and 'wedding_mappings' in auth_config:
231
+ wedding_mappings = auth_config['wedding_mappings']
232
+
233
+ # Search through all wedding mappings to find the user
234
+ for wedding_name, wedding_info in wedding_mappings.items():
235
+ if 'users' in wedding_info and username in wedding_info['users']:
236
+ return wedding_info['folder']
237
+
238
+ # Fallback to old hardcoded logic for backward compatibility
239
+ if username == 'demo':
240
+ return 'demo_data'
241
+ elif username == 'laraandumang':
242
+ return 'laraandumang'
243
+ else:
244
+ return 'demo_data' # Default fallback
245
+
246
+ except Exception as e:
247
+ st.error(f"Error getting user folder for {username}: {e}")
248
+ # Fallback to demo_data on error
249
+ return 'demo_data'
250
+
251
+ def get_wedding_info_for_user(username):
252
+ """Get wedding information for a specific user"""
253
+ try:
254
+ auth_config = st.session_state.get('auth_config')
255
+ if not auth_config:
256
+ auth_config = load_auth_config()
257
+
258
+ if auth_config and 'wedding_mappings' in auth_config:
259
+ wedding_mappings = auth_config['wedding_mappings']
260
+
261
+ for wedding_name, wedding_info in wedding_mappings.items():
262
+ if 'users' in wedding_info and username in wedding_info['users']:
263
+ return {
264
+ 'wedding_name': wedding_name,
265
+ 'folder': wedding_info['folder'],
266
+ 'users': wedding_info['users'],
267
+ 'display_name': wedding_info.get('wedding_name', wedding_name)
268
+ }
269
+
270
+ return None
271
+ except Exception as e:
272
+ st.error(f"Error getting wedding info for {username}: {e}")
273
+ return None
274
+
275
+ def main():
276
+ # Initialize session state
277
+ if 'config_manager' not in st.session_state:
278
+ st.session_state.config_manager = ConfigManager()
279
+
280
+ # Load authentication configuration
281
+ if 'auth_config' not in st.session_state:
282
+ st.session_state.auth_config = load_auth_config()
283
+
284
+ if st.session_state.auth_config is None:
285
+ st.error("Failed to load authentication configuration. Please check your Google Drive setup.")
286
+ st.stop()
287
+
288
+ # Create authenticator (auto_hash=True by default, so passwords will be hashed automatically)
289
+ authenticator = stauth.Authenticate(
290
+ st.session_state.auth_config['credentials'],
291
+ st.session_state.auth_config['cookie']['name'],
292
+ st.session_state.auth_config['cookie']['key'],
293
+ st.session_state.auth_config['cookie']['expiry_days']
294
+ )
295
+
296
+ # Check authentication status
297
+ if 'authentication_status' not in st.session_state:
298
+ st.session_state.authentication_status = None
299
+
300
+ # Check if user is already authenticated
301
+ if st.session_state.get('authentication_status'):
302
+ # User is authenticated, show main app
303
+ show_main_app(authenticator)
304
+ else:
305
+ # Show login page and handle authentication
306
+ show_login_page(authenticator)
307
+
308
+ def show_login_page(authenticator):
309
+ """Show the login page"""
310
+
311
+ # Hero Section
312
+ st.markdown("""
313
+ <div style="
314
+ background: linear-gradient(135deg, #2d5016, #4a7c59, #6b8e6b);
315
+ color: white;
316
+ padding: 4rem 2rem;
317
+ text-align: center;
318
+ border-radius: 15px;
319
+ margin-bottom: 3rem;
320
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
321
+ ">
322
+ <h1 style="font-size: 3.5rem; margin-bottom: 1rem; font-weight: 700;">💒 Wedding Planner</h1>
323
+ <h2 style="font-size: 1.8rem; margin-bottom: 2rem; font-weight: 300; opacity: 0.9;">
324
+ Your Complete Wedding Planning Solution
325
+ </h2>
326
+ <p style="font-size: 1.2rem; max-width: 600px; margin: 0 auto; line-height: 1.6;">
327
+ Organize your special day with our comprehensive wedding planning tool.
328
+ Manage guests, track tasks, coordinate vendors, and create unforgettable memories.
329
+ </p>
330
+ </div>
331
+ """, unsafe_allow_html=True)
332
+
333
+ # Features Section
334
+ st.markdown("## ✨ What You Can Do")
335
+
336
+ col1, col2, col3 = st.columns(3)
337
+
338
+ with col1:
339
+ st.markdown("""
340
+ <div style="
341
+ background: #f8f9fa;
342
+ padding: 2rem;
343
+ border-radius: 10px;
344
+ border-left: 4px solid #4a7c59;
345
+ margin-bottom: 2rem;
346
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
347
+ ">
348
+ <h3 style="color: #2d5016; margin-bottom: 1rem;">👥 Guest Management</h3>
349
+ <ul style="color: #666; line-height: 1.8;">
350
+ <li>Organize guest lists by groups</li>
351
+ <li>Track RSVP responses</li>
352
+ <li>Manage meal preferences</li>
353
+ <li>Send invitations</li>
354
+ </ul>
355
+ </div>
356
+ """, unsafe_allow_html=True)
357
+
358
+ with col2:
359
+ st.markdown("""
360
+ <div style="
361
+ background: #f8f9fa;
362
+ padding: 2rem;
363
+ border-radius: 10px;
364
+ border-left: 4px solid #4a7c59;
365
+ margin-bottom: 2rem;
366
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
367
+ ">
368
+ <h3 style="color: #2d5016; margin-bottom: 1rem;">📋 Task Tracking</h3>
369
+ <ul style="color: #666; line-height: 1.8;">
370
+ <li>Create and assign tasks</li>
371
+ <li>Set deadlines and priorities</li>
372
+ <li>Track progress</li>
373
+ <li>Organize by categories</li>
374
+ </ul>
375
+ </div>
376
+ """, unsafe_allow_html=True)
377
+
378
+ with col3:
379
+ st.markdown("""
380
+ <div style="
381
+ background: #f8f9fa;
382
+ padding: 2rem;
383
+ border-radius: 10px;
384
+ border-left: 4px solid #4a7c59;
385
+ margin-bottom: 2rem;
386
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
387
+ ">
388
+ <h3 style="color: #2d5016; margin-bottom: 1rem;">🏢 Vendor Management</h3>
389
+ <ul style="color: #666; line-height: 1.8;">
390
+ <li>Track vendor contacts</li>
391
+ <li>Manage payment schedules</li>
392
+ <li>Store contracts</li>
393
+ <li>Monitor bookings</li>
394
+ </ul>
395
+ </div>
396
+ """, unsafe_allow_html=True)
397
+
398
+ # Additional Features
399
+ col4, col5, col6 = st.columns(3)
400
+
401
+ with col4:
402
+ st.markdown("""
403
+ <div style="
404
+ background: #f8f9fa;
405
+ padding: 2rem;
406
+ border-radius: 10px;
407
+ border-left: 4px solid #4a7c59;
408
+ margin-bottom: 2rem;
409
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
410
+ ">
411
+ <h3 style="color: #2d5016; margin-bottom: 1rem;">👰 Wedding Party</h3>
412
+ <ul style="color: #666; line-height: 1.8;">
413
+ <li>Manage bridal party</li>
414
+ <li>Track responsibilities</li>
415
+ <li>Coordinate schedules</li>
416
+ <li>Store contact info</li>
417
+ </ul>
418
+ </div>
419
+ """, unsafe_allow_html=True)
420
+
421
+ with col5:
422
+ st.markdown("""
423
+ <div style="
424
+ background: #f8f9fa;
425
+ padding: 2rem;
426
+ border-radius: 10px;
427
+ border-left: 4px solid #4a7c59;
428
+ margin-bottom: 2rem;
429
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
430
+ ">
431
+ <h3 style="color: #2d5016; margin-bottom: 1rem;">📊 Dashboard</h3>
432
+ <ul style="color: #666; line-height: 1.8;">
433
+ <li>Visual progress tracking</li>
434
+ <li>Key metrics overview</li>
435
+ <li>Timeline management</li>
436
+ <li>Quick insights</li>
437
+ </ul>
438
+ </div>
439
+ """, unsafe_allow_html=True)
440
+
441
+ with col6:
442
+ st.markdown("""
443
+ <div style="
444
+ background: #f8f9fa;
445
+ padding: 2rem;
446
+ border-radius: 10px;
447
+ border-left: 4px solid #4a7c59;
448
+ margin-bottom: 2rem;
449
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
450
+ ">
451
+ <h3 style="color: #2d5016; margin-bottom: 1rem;">☁️ Cloud Sync</h3>
452
+ <ul style="color: #666; line-height: 1.8;">
453
+ <li>Google Drive integration</li>
454
+ <li>Automatic backups</li>
455
+ <li>Multi-device access</li>
456
+ <li>Real-time updates</li>
457
+ </ul>
458
+ </div>
459
+ """, unsafe_allow_html=True)
460
+
461
+ st.markdown("---")
462
+
463
+ # Login Section
464
+ st.markdown("## 🔐 Login to Your Wedding Planner")
465
+
466
+ # Login form
467
+ try:
468
+ authenticator.login(location='main')
469
+ except Exception as e:
470
+ st.error(f"Login error: {e}")
471
+
472
+ # Check authentication status and show appropriate message
473
+ if st.session_state.get('authentication_status') is False:
474
+ st.error("❌ Invalid username or password")
475
+ elif st.session_state.get('authentication_status') is None:
476
+ st.info("🔐 Please enter your username and password")
477
+ elif st.session_state.get('authentication_status'):
478
+ st.success(f"✅ Welcome, {st.session_state.get('name', 'User')}!")
479
+ st.rerun() # Refresh to show main app
480
+
481
+
482
+
483
+ def show_wedding_setup_form():
484
+ """Show the wedding setup form for creating a new wedding"""
485
+ st.markdown("### 📝 Create Your Wedding Configuration")
486
+
487
+ # Initialize session state for events and form data if not exists
488
+ if 'setup_events' not in st.session_state:
489
+ st.session_state.setup_events = []
490
+ if 'setup_form_data' not in st.session_state:
491
+ st.session_state.setup_form_data = {
492
+ 'partner1_name': '',
493
+ 'partner2_name': '',
494
+ 'venue_city': '',
495
+ 'wedding_start_date': date.today(),
496
+ 'wedding_end_date': date.today(),
497
+ 'custom_tags': '',
498
+ 'task_assignees': ''
499
+ }
500
+
501
+ # Basic wedding information form
502
+ with st.form("wedding_setup"):
503
+ st.markdown("#### Basic Wedding Information")
504
+
505
+ col1, col2 = st.columns(2)
506
+ with col1:
507
+ partner1_name = st.text_input("Partner 1 Name", value=st.session_state.setup_form_data['partner1_name'], placeholder="Enter first partner's name")
508
+ partner2_name = st.text_input("Partner 2 Name", value=st.session_state.setup_form_data['partner2_name'], placeholder="Enter second partner's name")
509
+ venue_city = st.text_input("City", value=st.session_state.setup_form_data['venue_city'], placeholder="Enter city")
510
+
511
+ with col2:
512
+ st.markdown("**Wedding Date Range**")
513
+ wedding_start_date = st.date_input("Start Date", value=st.session_state.setup_form_data['wedding_start_date'])
514
+ wedding_end_date = st.date_input("End Date", value=st.session_state.setup_form_data['wedding_end_date'])
515
+
516
+ if wedding_end_date < wedding_start_date:
517
+ st.error("End date must be after start date")
518
+ wedding_end_date = wedding_start_date
519
+
520
+ st.markdown("#### Task Organization")
521
+ st.info("Tasks will be automatically grouped by your wedding events.")
522
+
523
+ st.markdown("#### Custom Tags")
524
+ st.markdown("Enter custom tags (one per line):")
525
+ 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")
526
+
527
+ st.markdown("#### Task Assignees")
528
+ st.markdown("Enter people who will regularly be assigned tasks (one per line):")
529
+ 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")
530
+
531
+ form_submitted = st.form_submit_button("Update Wedding Information")
532
+
533
+ if form_submitted:
534
+ # Update session state with form data
535
+ st.session_state.setup_form_data = {
536
+ 'partner1_name': partner1_name,
537
+ 'partner2_name': partner2_name,
538
+ 'venue_city': venue_city,
539
+ 'wedding_start_date': wedding_start_date,
540
+ 'wedding_end_date': wedding_end_date,
541
+ 'custom_tags': custom_tags,
542
+ 'task_assignees': task_assignees
543
+ }
544
+ st.success("Wedding information updated!")
545
+ st.rerun()
546
+
547
+ # Event management section (outside form)
548
+ st.markdown("#### Wedding Events")
549
+ st.markdown("Define all your wedding events with their details:")
550
+
551
+ # Add/Remove event buttons
552
+ col1, col2 = st.columns(2)
553
+ with col1:
554
+ if st.button("➕ Add Event"):
555
+ # Set default date to wedding start date
556
+ wedding_start = st.session_state.setup_form_data['wedding_start_date']
557
+ st.session_state.setup_events.append({
558
+ "name": "New Event",
559
+ "description": "",
560
+ "date_offset": 0,
561
+ "requires_meal_choice": False,
562
+ "meal_options": [],
563
+ "location": "",
564
+ "address": ""
565
+ })
566
+ st.rerun()
567
+
568
+ with col2:
569
+ if len(st.session_state.setup_events) > 0 and st.button("➖ Remove Last Event"):
570
+ st.session_state.setup_events.pop()
571
+ st.rerun()
572
+
573
+ # Display events
574
+ if st.session_state.setup_events:
575
+ for i, event in enumerate(st.session_state.setup_events):
576
+ with st.expander(f"Event {i+1}: {event['name']}", expanded=True):
577
+ col1, col2 = st.columns(2)
578
+
579
+ with col1:
580
+ event_name = st.text_input("Event Name", value=event['name'], key=f"event_name_{i}")
581
+ event_description = st.text_input("Time", value=event['description'], placeholder="e.g., 2:00 PM, 6:30 PM", key=f"event_desc_{i}")
582
+ event_location = st.text_input("Location Name", value=event.get('location', ''), placeholder="e.g., Central Park, Grand Ballroom", key=f"event_location_{i}")
583
+
584
+ with col2:
585
+ # Get wedding date range
586
+ wedding_start = st.session_state.setup_form_data['wedding_start_date']
587
+ wedding_end = st.session_state.setup_form_data['wedding_end_date']
588
+
589
+ # Calculate current event date from date_offset
590
+ current_event_date = wedding_start + timedelta(days=event['date_offset'])
591
+
592
+ # Use date input without constraints - allow any date
593
+ event_date = st.date_input(
594
+ "Event Date",
595
+ value=current_event_date,
596
+ key=f"event_date_{i}",
597
+ help="Select any date for this event"
598
+ )
599
+
600
+ # Show warning if date is outside wedding range
601
+ if event_date < wedding_start or event_date > wedding_end:
602
+ st.warning(f"⚠️ Selected date is outside your wedding date range ({wedding_start.strftime('%B %d, %Y')} - {wedding_end.strftime('%B %d, %Y')})")
603
+
604
+ requires_meal_choice = st.checkbox("Requires Meal Choice", value=event['requires_meal_choice'], key=f"event_meal_{i}")
605
+
606
+ # Meal options section (only show if meal choice is required)
607
+ if requires_meal_choice:
608
+ st.markdown("**Meal Options**")
609
+ st.markdown("Enter meal options (one per line):")
610
+ current_meal_options = event.get('meal_options', [])
611
+ meal_options_text = '\n'.join(current_meal_options) if current_meal_options else ''
612
+ 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)
613
+ else:
614
+ meal_options = ""
615
+
616
+ 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)
617
+
618
+ # Calculate date_offset from the selected date
619
+ date_offset = (event_date - wedding_start).days
620
+
621
+ # Parse meal options
622
+ meal_options_list = []
623
+ if requires_meal_choice and meal_options:
624
+ meal_options_list = [option.strip() for option in meal_options.split('\n') if option.strip()]
625
+
626
+ # Update session state
627
+ st.session_state.setup_events[i] = {
628
+ "name": event_name,
629
+ "description": event_description,
630
+ "date_offset": date_offset,
631
+ "requires_meal_choice": requires_meal_choice,
632
+ "meal_options": meal_options_list,
633
+ "location": event_location,
634
+ "address": event_address
635
+ }
636
+ else:
637
+ st.info("No events added yet. Click 'Add Event' to get started!")
638
+
639
+ # Save configuration button (after event management)
640
+ st.markdown("---")
641
+ if st.button("Save Configuration", type="primary"):
642
+ # Get form values from session state
643
+ form_data = st.session_state.setup_form_data
644
+
645
+ if form_data['partner1_name'] and form_data['partner2_name'] and form_data['wedding_start_date'] and form_data['wedding_end_date']:
646
+ # Parse tags (task groups will be auto-generated from events)
647
+ custom_tags_list = [tag.strip() for tag in form_data['custom_tags'].split('\n') if tag.strip()]
648
+ task_assignees_list = [assignee.strip() for assignee in form_data['task_assignees'].split('\n') if assignee.strip()]
649
+
650
+ # Create configuration
651
+ config = {
652
+ 'wedding_info': {
653
+ 'partner1_name': form_data['partner1_name'],
654
+ 'partner2_name': form_data['partner2_name'],
655
+ 'wedding_start_date': form_data['wedding_start_date'].isoformat(),
656
+ 'wedding_end_date': form_data['wedding_end_date'].isoformat(),
657
+ 'venue_city': form_data['venue_city']
658
+ },
659
+ 'custom_settings': {
660
+ 'custom_tags': custom_tags_list,
661
+ 'task_assignees': task_assignees_list
662
+ },
663
+ 'wedding_events': st.session_state.setup_events
664
+ }
665
+
666
+ # Save configuration
667
+ st.session_state.config_manager.save_config(config)
668
+ # Clear setup session state
669
+ if 'setup_events' in st.session_state:
670
+ del st.session_state.setup_events
671
+ if 'setup_form_data' in st.session_state:
672
+ del st.session_state.setup_form_data
673
+ if 'show_setup_form' in st.session_state:
674
+ del st.session_state.show_setup_form
675
+ st.success("Configuration saved successfully!")
676
+ st.rerun()
677
+ else:
678
+ st.error("Please fill in at least the partner names and wedding date range in the form above.")
679
+
680
+
681
+ def show_main_app(authenticator):
682
+ # Get current user from session state (set by authenticator.login)
683
+ username = st.session_state.get('username')
684
+ name = st.session_state.get('name')
685
+
686
+ if not username or not name:
687
+ st.error("Authentication error: Missing user information")
688
+ return
689
+
690
+ # Set user folder based on username
691
+ user_folder = get_user_folder_from_username(username)
692
+
693
+ # Get wedding info for the user (for future use)
694
+ wedding_info = get_wedding_info_for_user(username)
695
+
696
+ # Update config manager to use the correct user folder
697
+ config_manager = st.session_state.config_manager
698
+ config_manager.set_user_folder(user_folder)
699
+
700
+ # Check if we need to load data (either not initialized or user changed)
701
+ current_user_folder = config_manager.get_current_user_folder()
702
+ user_changed = st.session_state.get('last_user_folder') != user_folder
703
+
704
+ if not st.session_state.get('app_initialized', False) or user_changed:
705
+ with st.spinner(f"Loading {name}'s wedding data..."):
706
+ if user_folder == 'demo_data':
707
+ if config_manager.load_demo_data_from_drive():
708
+ st.session_state.app_initialized = True
709
+ st.session_state.last_user_folder = user_folder
710
+ st.success("✅ Demo data loaded successfully!")
711
+ else:
712
+ st.error("Failed to load demo data.")
713
+ return
714
+ else:
715
+ if config_manager.load_existing_data_from_drive():
716
+ st.session_state.app_initialized = True
717
+ st.session_state.last_user_folder = user_folder
718
+ else:
719
+ st.error("Failed to load wedding data.")
720
+ return
721
+
722
+ # Load config
723
+ config = st.session_state.config_manager.load_config()
724
+ wedding_info = config.get('wedding_info', {})
725
+
726
+ # Check demo mode status
727
+ is_demo_mode = st.session_state.config_manager.is_demo_mode()
728
+
729
+ # Header
730
+ partner1 = wedding_info.get('partner1_name', 'Partner 1')
731
+ partner2 = wedding_info.get('partner2_name', 'Partner 2')
732
+ venue_city = wedding_info.get('venue_city', '')
733
+ wedding_start_str = wedding_info.get('wedding_start_date', '')
734
+ wedding_end_str = wedding_info.get('wedding_end_date', '')
735
+
736
+ # Build base header text with location
737
+ if venue_city:
738
+ header_text = f"{partner1} & {partner2}'s Wedding Planner - {venue_city} \n"
739
+ else:
740
+ header_text = f"{partner1} & {partner2}'s Wedding Planner \n"
741
+
742
+ # Add wedding mapping info if available
743
+ user_wedding_info = get_wedding_info_for_user(username)
744
+
745
+ # Add demo mode indicator
746
+ if is_demo_mode:
747
+ header_text += "🎭 DEMO MODE - Sample Data"
748
+
749
+ if wedding_start_str and wedding_end_str:
750
+ try:
751
+ wedding_start = datetime.fromisoformat(wedding_start_str).date()
752
+ wedding_end = datetime.fromisoformat(wedding_end_str).date()
753
+ today = date.today()
754
+
755
+ if today < wedding_start:
756
+ days_until = (wedding_start - today).days
757
+ header_text += f"\n{days_until} days until wedding festivities begin!"
758
+ elif wedding_start <= today <= wedding_end:
759
+ header_text += " - Wedding festivities are happening now! 🎉"
760
+ else:
761
+ days_since = (today - wedding_end).days
762
+ header_text += f" - {days_since} days since the wedding celebration ended!"
763
+ except:
764
+ pass # Keep the base header text if date parsing fails
765
+
766
+ st.markdown(f'<div class="main-header"><h1>{header_text}</h1></div>', unsafe_allow_html=True)
767
+
768
+ # Add Google Drive sync status and push button
769
+ show_google_drive_sync_status()
770
+
771
+ # Sidebar navigation
772
+ with st.sidebar:
773
+ # User info and logout
774
+ # Extract first name for a more friendly greeting
775
+ first_name = name.split()[0] if name else "User"
776
+ st.markdown(f"**Welcome, {first_name}!**")
777
+ if authenticator.logout(location='sidebar', key='logout_button'):
778
+ # Clear session state on logout
779
+ for key in list(st.session_state.keys()):
780
+ if key not in ['config_manager', 'auth_config']:
781
+ del st.session_state[key]
782
+
783
+ # Reset app initialization state
784
+ st.session_state.app_initialized = False
785
+ st.session_state.last_user_folder = None
786
+
787
+ # Reset config manager state
788
+ if 'config_manager' in st.session_state:
789
+ st.session_state.config_manager.reset_app_state()
790
+ st.session_state.config_manager.user_folder = None
791
+
792
+ st.rerun()
793
+
794
+ st.markdown("---")
795
+ st.markdown("### Navigation")
796
+ page = st.radio(
797
+ "Choose a page:",
798
+ ["Dashboard", "Tasks", "Guest Management", "Wedding Party", "Wedding Overview", "Vendors & Purchases", "Settings"]
799
+ )
800
+
801
+ # Add Google Drive sync button in sidebar if enabled
802
+ config_manager = st.session_state.config_manager
803
+ drive_status = config_manager.get_google_drive_status()
804
+
805
+ if drive_status['enabled']:
806
+ st.markdown("---")
807
+ st.markdown("### ☁️ Google Drive")
808
+
809
+ modified_files = config_manager.get_modified_files()
810
+
811
+ # Always show push button
812
+ if modified_files:
813
+ st.warning(f"📝 {len(modified_files)} unsaved changes")
814
+ if st.button("📤 Push Changes", type="primary", help="Save changes to Google Drive", key="sidebar_push"):
815
+ with st.spinner("Pushing changes..."):
816
+ if config_manager.manual_sync_to_drive():
817
+ st.success("✅ Changes saved!")
818
+ st.rerun()
819
+ else:
820
+ st.error("❌ Failed to save changes")
821
+ else:
822
+ st.success("✅ All changes saved")
823
+ if st.button("📤 Push to Drive", help="Save current data to Google Drive", key="sidebar_push_all"):
824
+ with st.spinner("Pushing to Drive..."):
825
+ if config_manager.manual_sync_to_drive():
826
+ st.success("✅ Data saved!")
827
+ st.rerun()
828
+ else:
829
+ st.error("❌ Failed to save")
830
+
831
+ # Always show pull button
832
+ if st.button("🔄 Pull Latest", help="Get latest from Google Drive", key="sidebar_pull"):
833
+ with st.spinner("Pulling latest..."):
834
+ if config_manager.manual_sync_from_drive():
835
+ st.success("✅ Latest loaded!")
836
+ st.rerun()
837
+ else:
838
+ st.error("❌ Failed to load changes")
839
+
840
+ # Route to appropriate page
841
+ if page == "Dashboard":
842
+ Dashboard().render(config)
843
+ elif page == "Tasks":
844
+ TasksManager().render(config)
845
+ elif page == "Guest Management":
846
+ GuestManager().render(config)
847
+ elif page == "Wedding Party":
848
+ WeddingPartyManager().render(config)
849
+ elif page == "Wedding Overview":
850
+ show_wedding_timeline_page(config)
851
+ elif page == "Vendors & Purchases":
852
+ VendorManager().render(config)
853
+ elif page == "Settings":
854
+ show_settings_page(config)
855
+
856
+ def show_wedding_timeline_page(config):
857
+ st.markdown("## 📅 Wedding Overview")
858
+
859
+ # Show wedding events directly without tabs
860
+ show_wedding_events_section(config)
861
+
862
+ def get_vendors_for_event(event_name, vendors_data):
863
+ """Get vendors associated with a specific event, handling multiple categories per vendor"""
864
+ event_vendors = []
865
+ for vendor in vendors_data:
866
+ vendor_events = vendor.get('events', [])
867
+ if event_name in vendor_events:
868
+ # Get all categories for this vendor
869
+ categories = vendor.get('categories', [])
870
+ primary_category = vendor.get('category', '')
871
+
872
+ # If no categories array, use the primary category
873
+ if not categories and primary_category:
874
+ categories = [primary_category]
875
+
876
+ # If still no categories, use a default
877
+ if not categories:
878
+ categories = ['Vendor/Service']
879
+
880
+ # Create an entry for each category this vendor serves
881
+ for category in categories:
882
+ event_vendors.append({
883
+ 'name': vendor.get('name', ''),
884
+ 'category': category,
885
+ 'status': vendor.get('status', ''),
886
+ 'vendor_id': vendor.get('id', '') # Add ID to help identify duplicates
887
+ })
888
+
889
+ return event_vendors
890
+
891
+ def get_meal_choices_for_event(event_name, rsvp_data, meal_options):
892
+ """Get meal choice counts for a specific event from RSVP data"""
893
+ meal_counts = {}
894
+
895
+ # Initialize counts for all meal options
896
+ for option in meal_options:
897
+ meal_counts[option] = 0
898
+
899
+ # Count meal choices from RSVP data
900
+ for group_code, group_data in rsvp_data.items():
901
+ event_responses = group_data.get('event_responses', {})
902
+ if event_name in event_responses:
903
+ event_data = event_responses[event_name]
904
+ meal_choices = event_data.get('meal_choice', {})
905
+
906
+ for attendee, choice in meal_choices.items():
907
+ if choice in meal_counts:
908
+ meal_counts[choice] += 1
909
+
910
+ return meal_counts
911
+
912
+ def show_wedding_events_section(config):
913
+ st.markdown("## 📅 Wedding Events")
914
+
915
+ wedding_events = config.get('wedding_events', [])
916
+ wedding_info = config.get('wedding_info', {})
917
+
918
+ if not wedding_events:
919
+ st.info("No events configured yet. Please complete the setup to define your wedding events.")
920
+ return
921
+
922
+ # Get wedding dates
923
+ wedding_start_str = wedding_info.get('wedding_start_date', '')
924
+ wedding_end_str = wedding_info.get('wedding_end_date', '')
925
+
926
+ if wedding_start_str and wedding_end_str:
927
+ try:
928
+ wedding_start = datetime.fromisoformat(wedding_start_str).date()
929
+ wedding_end = datetime.fromisoformat(wedding_end_str).date()
930
+ except:
931
+ st.error("Invalid wedding date format. Please check your settings.")
932
+ return
933
+ else:
934
+ st.warning("Wedding dates not set. Please complete the setup.")
935
+ return
936
+
937
+ # Load vendors and RSVP data
938
+ config_manager = ConfigManager()
939
+ vendors_data = config_manager.load_json_data('vendors.json')
940
+ rsvp_data = config_manager.load_json_data('rsvp_data.json')
941
+
942
+ # Display events
943
+ st.markdown(f"### Your Wedding Events ({len(wedding_events)} total)")
944
+
945
+ # Group events by day
946
+ events_by_day = {}
947
+ for event in wedding_events:
948
+ event_date = wedding_start + timedelta(days=event.get('date_offset', 0))
949
+ day_key = event_date.strftime('%Y-%m-%d')
950
+ if day_key not in events_by_day:
951
+ events_by_day[day_key] = []
952
+ events_by_day[day_key].append(event)
953
+
954
+ # Sort days
955
+ sorted_days = sorted(events_by_day.keys())
956
+
957
+ for day in sorted_days:
958
+ day_date = datetime.fromisoformat(day).date()
959
+ day_events = events_by_day[day]
960
+
961
+ # Determine day label
962
+ if day_date == wedding_start:
963
+ day_label = "Day 1 - Wedding Start"
964
+ elif day_date == wedding_end:
965
+ day_label = "Final Day - Wedding End"
966
+ else:
967
+ days_from_start = (day_date - wedding_start).days
968
+ day_label = f"Day {days_from_start + 1}"
969
+
970
+ st.markdown(f"#### {day_label} - {day_date.strftime('%B %d, %Y')}")
971
+
972
+ for event in day_events:
973
+ # Simple event display
974
+ time_info = event.get('description', '') or 'Time TBD'
975
+ location = event.get('location', '') or 'Location TBD'
976
+ address = event.get('address', '')
977
+ meal_required = event.get('requires_meal_choice', False)
978
+ event_name = event.get('name', 'Untitled Event')
979
+
980
+ st.markdown(f"**{event_name}**")
981
+ st.markdown(f"🕐 **Time:** {time_info}")
982
+ st.markdown(f"📍 **Location:** {location}")
983
+ if address:
984
+ st.markdown(f"🏠 **Address:** {address}")
985
+ st.markdown(f"🍽️ **Meal Choice:** {'Required' if meal_required else 'Not Required'}")
986
+
987
+ # Display meal choices and counts if meal choice is required
988
+ if meal_required:
989
+ meal_options = event.get('meal_options', [])
990
+ if meal_options:
991
+ meal_counts = get_meal_choices_for_event(event_name, rsvp_data, meal_options)
992
+ st.markdown("🍽️ **Meal Choices:**")
993
+ for option in meal_options:
994
+ count = meal_counts.get(option, 0)
995
+ st.markdown(f" • **{option}:** {count} orders")
996
+ else:
997
+ st.markdown("🍽️ **Meal Choices:** No options configured")
998
+
999
+ # Display vendors for this event
1000
+ event_vendors = get_vendors_for_event(event_name, vendors_data)
1001
+ if event_vendors:
1002
+ st.markdown("🏢 **Vendors:**")
1003
+
1004
+ # Group vendors by name to handle multiple categories
1005
+ vendors_by_name = {}
1006
+ for vendor in event_vendors:
1007
+ vendor_name = vendor['name']
1008
+ if vendor_name not in vendors_by_name:
1009
+ vendors_by_name[vendor_name] = {
1010
+ 'categories': [],
1011
+ 'status': vendor['status']
1012
+ }
1013
+ vendors_by_name[vendor_name]['categories'].append(vendor['category'])
1014
+
1015
+ # Display grouped vendors
1016
+ for vendor_name, vendor_info in vendors_by_name.items():
1017
+ status_emoji = "✅" if vendor_info['status'] == "Booked" else "⏳" if vendor_info['status'] == "Researching" else "📋"
1018
+ categories_text = ", ".join(vendor_info['categories'])
1019
+ st.markdown(f" • {status_emoji} **{categories_text}:** {vendor_name}")
1020
+ else:
1021
+ st.markdown("🏢 **Vendors:** None assigned")
1022
+
1023
+ st.markdown("---")
1024
+
1025
+ # Event summary
1026
+ st.markdown("### Event Summary")
1027
+
1028
+ col1, col2, col3 = st.columns(3)
1029
+
1030
+ with col1:
1031
+ total_events = len(wedding_events)
1032
+ st.metric("Total Events", total_events)
1033
+
1034
+ with col2:
1035
+ meal_events = len([e for e in wedding_events if e.get('requires_meal_choice', False)])
1036
+ st.metric("Events with Meals", meal_events)
1037
+
1038
+ with col3:
1039
+ days_span = (wedding_end - wedding_start).days + 1
1040
+ st.metric("Celebration Days", days_span)
1041
+
1042
+ def show_settings_page(config):
1043
+ st.markdown("### Settings")
1044
+
1045
+ # Check demo mode status
1046
+ is_demo_mode = st.session_state.config_manager.is_demo_mode()
1047
+
1048
+ # Demo mode toggle at the top
1049
+ st.markdown("#### Demo Mode")
1050
+ col1, col2 = st.columns([3, 1])
1051
+ with col1:
1052
+ if is_demo_mode:
1053
+ 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.")
1054
+ else:
1055
+ st.info("📝 **Demo Mode is OFF** - You are viewing your actual wedding data.")
1056
+
1057
+ with col2:
1058
+ if st.button("Toggle Demo Mode", type="secondary"):
1059
+ if st.session_state.config_manager.toggle_demo_mode():
1060
+ st.success("Demo mode toggled! Please refresh the page.")
1061
+ st.rerun()
1062
+ else:
1063
+ st.error("Failed to toggle demo mode.")
1064
+
1065
+ # Create tabs for different settings
1066
+ tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs(["Edit Configuration", "Manage Events", "Google Drive", "Cache Management", "Current Configuration", "Reset"])
1067
+
1068
+ with tab1:
1069
+ st.markdown("#### Edit Wedding Configuration")
1070
+
1071
+ # Initialize session state for editing if not exists
1072
+ if 'edit_config' not in st.session_state:
1073
+ st.session_state.edit_config = config.copy()
1074
+
1075
+ with st.form("edit_wedding_config"):
1076
+ st.markdown("##### Basic Wedding Information")
1077
+
1078
+ col1, col2 = st.columns(2)
1079
+ with col1:
1080
+ partner1_name = st.text_input("Partner 1 Name", value=st.session_state.edit_config.get('wedding_info', {}).get('partner1_name', ''))
1081
+ partner2_name = st.text_input("Partner 2 Name", value=st.session_state.edit_config.get('wedding_info', {}).get('partner2_name', ''))
1082
+ venue_city = st.text_input("City", value=st.session_state.edit_config.get('wedding_info', {}).get('venue_city', ''))
1083
+
1084
+ with col2:
1085
+ # Get current dates
1086
+ wedding_start_str = st.session_state.edit_config.get('wedding_info', {}).get('wedding_start_date', '')
1087
+ wedding_end_str = st.session_state.edit_config.get('wedding_info', {}).get('wedding_end_date', '')
1088
+
1089
+ try:
1090
+ if wedding_start_str:
1091
+ wedding_start = datetime.fromisoformat(wedding_start_str).date()
1092
+ else:
1093
+ wedding_start = date.today()
1094
+
1095
+ if wedding_end_str:
1096
+ wedding_end = datetime.fromisoformat(wedding_end_str).date()
1097
+ else:
1098
+ wedding_end = date.today()
1099
+ except:
1100
+ wedding_start = date.today()
1101
+ wedding_end = date.today()
1102
+
1103
+ wedding_start_date = st.date_input("Wedding Start Date", value=wedding_start)
1104
+ wedding_end_date = st.date_input("Wedding End Date", value=wedding_end)
1105
+
1106
+ if wedding_end_date < wedding_start_date:
1107
+ st.error("End date must be after start date")
1108
+ wedding_end_date = wedding_start_date
1109
+
1110
+ st.markdown("##### Custom Tags")
1111
+ current_tags = st.session_state.edit_config.get('custom_settings', {}).get('custom_tags', [])
1112
+ custom_tags_text = '\n'.join(current_tags) if current_tags else ''
1113
+ custom_tags = st.text_area("Custom Tags (one per line)", value=custom_tags_text, placeholder="e.g.,\nUrgent\nHigh Priority\nDeposit Required")
1114
+
1115
+ st.markdown("##### Task Assignees")
1116
+ current_assignees = st.session_state.edit_config.get('custom_settings', {}).get('task_assignees', [])
1117
+ task_assignees_text = '\n'.join(current_assignees) if current_assignees else ''
1118
+ task_assignees = st.text_area("Task Assignees (one per line)", value=task_assignees_text, placeholder="e.g.,\nMom\nDad\nWedding Planner\nBest Friend\nCoordinator")
1119
+
1120
+ submitted = st.form_submit_button("Save Changes", type="primary")
1121
+
1122
+ if submitted:
1123
+ # Update the configuration
1124
+ updated_config = st.session_state.edit_config.copy()
1125
+ updated_config['wedding_info'] = {
1126
+ 'partner1_name': partner1_name,
1127
+ 'partner2_name': partner2_name,
1128
+ 'venue_city': venue_city,
1129
+ 'wedding_start_date': wedding_start_date.isoformat(),
1130
+ 'wedding_end_date': wedding_end_date.isoformat()
1131
+ }
1132
+
1133
+ # Parse custom tags and task assignees
1134
+ custom_tags_list = [tag.strip() for tag in custom_tags.split('\n') if tag.strip()]
1135
+ task_assignees_list = [assignee.strip() for assignee in task_assignees.split('\n') if assignee.strip()]
1136
+ updated_config['custom_settings'] = {
1137
+ 'custom_tags': custom_tags_list,
1138
+ 'task_assignees': task_assignees_list
1139
+ }
1140
+
1141
+ # Save the updated configuration
1142
+ st.session_state.config_manager.save_config(updated_config)
1143
+ st.success("Configuration updated successfully!")
1144
+ st.rerun()
1145
+
1146
+ with tab2:
1147
+ show_event_management_section(config)
1148
+
1149
+ with tab3:
1150
+ show_google_drive_section()
1151
+
1152
+ with tab4:
1153
+ show_cache_management_section()
1154
+
1155
+ with tab5:
1156
+ st.markdown("#### Current Configuration")
1157
+ st.json(config)
1158
+
1159
+ with tab6:
1160
+ st.markdown("#### Reset Configuration")
1161
+ st.warning("⚠️ This will permanently delete all your wedding configuration data. This action cannot be undone.")
1162
+
1163
+ if st.button("Reset Configuration", type="secondary"):
1164
+ if st.session_state.config_manager.reset_config():
1165
+ st.success("Configuration reset! Please refresh the page.")
1166
+ st.rerun()
1167
+
1168
+ def show_cache_management_section():
1169
+ """Show cache management section in settings"""
1170
+ st.markdown("#### Cache Management")
1171
+
1172
+ config_manager = st.session_state.config_manager
1173
+
1174
+ # Get cache status
1175
+ cache_status = config_manager.get_cache_status()
1176
+
1177
+ st.markdown("**Current Cache Status:**")
1178
+
1179
+ col1, col2 = st.columns(2)
1180
+
1181
+ with col1:
1182
+ st.metric("Total Cached Items", cache_status['total_cached'])
1183
+ if cache_status['config_cached']:
1184
+ st.success("✅ Wedding configuration cached")
1185
+ else:
1186
+ st.info("ℹ️ Wedding configuration not cached")
1187
+
1188
+ with col2:
1189
+ if cache_status['cached_data_files']:
1190
+ st.success(f"✅ {len(cache_status['cached_data_files'])} data files cached")
1191
+ with st.expander("View cached files"):
1192
+ for filename in cache_status['cached_data_files']:
1193
+ st.markdown(f"• {filename}")
1194
+ else:
1195
+ st.info("ℹ️ No data files cached")
1196
+
1197
+ st.markdown("---")
1198
+
1199
+ # Cache management buttons
1200
+ st.markdown("**Cache Actions:**")
1201
+
1202
+ col1, col2, col3 = st.columns(3)
1203
+
1204
+ with col1:
1205
+ if st.button("🗑️ Clear All Cache", help="Clear all cached data to force reload from source"):
1206
+ config_manager.clear_cache()
1207
+ st.success("Cache cleared! Data will be reloaded from source on next access.")
1208
+ st.rerun()
1209
+
1210
+ with col2:
1211
+ if st.button("🔄 Refresh Cache", help="Reload all data from Google Drive and update cache"):
1212
+ if config_manager.google_drive_enabled:
1213
+ with st.spinner("Refreshing cache from Google Drive..."):
1214
+ if config_manager.manual_sync_from_drive():
1215
+ st.success("Cache refreshed from Google Drive!")
1216
+ st.rerun()
1217
+ else:
1218
+ st.error("Failed to refresh cache from Google Drive")
1219
+ else:
1220
+ st.warning("Google Drive not enabled. Cannot refresh from Drive.")
1221
+
1222
+ with col3:
1223
+ if st.button("ℹ️ Cache Info", help="Show detailed cache information"):
1224
+ st.info("Cache helps improve performance by storing data in memory. Data is automatically cached when first loaded and updated when modified.")
1225
+
1226
+ def show_google_drive_status_setup():
1227
+ """Show Google Drive status on the setup page"""
1228
+ config_manager = st.session_state.config_manager
1229
+ drive_status = config_manager.get_google_drive_status()
1230
+
1231
+ # Create an expander for Google Drive status
1232
+ with st.expander("🔗 Google Drive Connection Status", expanded=False):
1233
+ # Status display
1234
+ if drive_status['enabled']:
1235
+ if drive_status['status'] == 'Online':
1236
+ st.success(f"✅ {drive_status['message']}")
1237
+ elif drive_status['status'] == 'Offline':
1238
+ st.warning(f"⚠️ {drive_status['message']}")
1239
+ else:
1240
+ st.error(f"❌ {drive_status['message']}")
1241
+ else:
1242
+ st.info(f"ℹ️ {drive_status['message']}")
1243
+
1244
+ # Show files if available
1245
+ if drive_status['enabled'] and 'files' in drive_status:
1246
+ st.markdown("**Files found in Google Drive:**")
1247
+ for file_name in drive_status['files']:
1248
+ friendly_name = get_friendly_file_name(file_name)
1249
+ st.markdown(f"• {friendly_name}")
1250
+
1251
+ # Manual sync buttons (if enabled) - only show pull and load options
1252
+ if drive_status['enabled']:
1253
+ st.markdown("**Manual Sync:**")
1254
+ col1, col2 = st.columns(2)
1255
+
1256
+ with col1:
1257
+ if st.button("📥 Sync from Google Drive", help="Download latest data from Google Drive", key="setup_sync_from"):
1258
+ with st.spinner("Syncing from Google Drive..."):
1259
+ if config_manager.manual_sync_from_drive():
1260
+ st.success("Successfully synced from Google Drive!")
1261
+ st.rerun()
1262
+ else:
1263
+ st.error("Failed to sync from Google Drive")
1264
+
1265
+ with col2:
1266
+ if st.button("🔄 Load Existing Data", help="Load existing wedding data from Google Drive", key="setup_load_existing"):
1267
+ with st.spinner("Loading existing data from Google Drive..."):
1268
+ if config_manager.load_existing_data_from_drive():
1269
+ st.success("Successfully loaded existing data from Google Drive!")
1270
+ st.info("Your wedding data has been loaded. The page will refresh to show your wedding planner.")
1271
+ st.rerun()
1272
+ else:
1273
+ st.error("Failed to load existing data from Google Drive")
1274
+
1275
+ # Configuration help
1276
+ if not drive_status['enabled']:
1277
+ st.markdown("**To enable Google Drive integration:**")
1278
+ st.markdown("Set the following **secrets** in your Hugging Face Space settings:")
1279
+ st.code("""
1280
+ GOOGLE_DRIVE_FOLDER_ID=your_folder_id
1281
+ GOOGLE_PROJECT_ID=your_project_id
1282
+ GOOGLE_PRIVATE_KEY_ID=your_private_key_id
1283
+ GOOGLE_PRIVATE_KEY=your_private_key
1284
+ GOOGLE_CLIENT_EMAIL=your_client_email
1285
+ GOOGLE_CLIENT_ID=your_client_id
1286
+ """)
1287
+
1288
+ # Debug information
1289
+ st.markdown("**Debug Information:**")
1290
+ folder_id = os.getenv('GOOGLE_DRIVE_FOLDER_ID')
1291
+ if folder_id:
1292
+ st.success(f"✅ GOOGLE_DRIVE_FOLDER_ID found: {folder_id[:10]}...")
1293
+ else:
1294
+ st.error("❌ GOOGLE_DRIVE_FOLDER_ID not found")
1295
+
1296
+ # Check other variables
1297
+ project_id = os.getenv('GOOGLE_PROJECT_ID')
1298
+ client_email = os.getenv('GOOGLE_CLIENT_EMAIL')
1299
+ private_key_id = os.getenv('GOOGLE_PRIVATE_KEY_ID')
1300
+ private_key = os.getenv('GOOGLE_PRIVATE_KEY')
1301
+ client_id = os.getenv('GOOGLE_CLIENT_ID')
1302
+
1303
+ if project_id:
1304
+ st.success(f"✅ GOOGLE_PROJECT_ID found: {project_id[:10]}...")
1305
+ else:
1306
+ st.error("❌ GOOGLE_PROJECT_ID not found")
1307
+
1308
+ if client_email:
1309
+ st.success(f"✅ GOOGLE_CLIENT_EMAIL found: {client_email}")
1310
+ else:
1311
+ st.error("❌ GOOGLE_CLIENT_EMAIL not found")
1312
+
1313
+ if private_key_id:
1314
+ st.success(f"✅ GOOGLE_PRIVATE_KEY_ID found: {private_key_id[:10]}...")
1315
+ else:
1316
+ st.error("❌ GOOGLE_PRIVATE_KEY_ID not found")
1317
+
1318
+ if private_key:
1319
+ st.success(f"✅ GOOGLE_PRIVATE_KEY found: {len(private_key)} characters")
1320
+ else:
1321
+ st.error("❌ GOOGLE_PRIVATE_KEY not found")
1322
+
1323
+ if client_id:
1324
+ st.success(f"✅ GOOGLE_CLIENT_ID found: {client_id[:10]}...")
1325
+ else:
1326
+ st.error("❌ GOOGLE_CLIENT_ID not found")
1327
+
1328
+ # Check if all required variables are present
1329
+ all_required = all([folder_id, project_id, private_key_id, private_key, client_email, client_id])
1330
+ if all_required:
1331
+ st.success("🎉 All required secrets are present!")
1332
+ st.info("If Google Drive is still not working, there might be an authentication issue. Try restarting your Space.")
1333
+
1334
+ # Test file writing permissions
1335
+ st.markdown("**Testing file permissions:**")
1336
+ try:
1337
+ test_file = "/tmp/test_write.txt"
1338
+ with open(test_file, 'w') as f:
1339
+ f.write("test")
1340
+ st.success("✅ Can write to /tmp directory")
1341
+ os.remove(test_file)
1342
+ except Exception as e:
1343
+ st.error(f"❌ Cannot write to /tmp directory: {e}")
1344
+
1345
+ try:
1346
+ test_dir = "wedding_data"
1347
+ os.makedirs(test_dir, exist_ok=True)
1348
+ test_file = os.path.join(test_dir, "test.txt")
1349
+ with open(test_file, 'w') as f:
1350
+ f.write("test")
1351
+ st.success("✅ Can write to wedding_data directory")
1352
+ os.remove(test_file)
1353
+ except Exception as e:
1354
+ st.error(f"❌ Cannot write to wedding_data directory: {e}")
1355
+ else:
1356
+ st.warning("⚠️ Some required secrets are missing. Please add all secrets and restart your Space.")
1357
+
1358
+ def get_friendly_file_name(filename):
1359
+ """Convert technical file names to user-friendly names"""
1360
+ friendly_names = {
1361
+ 'wedding_config.json': 'Wedding Configuration',
1362
+ 'guest_list_data.json': 'Guest List',
1363
+ 'rsvp_data.json': 'RSVP Responses',
1364
+ 'tasks.json': 'Tasks',
1365
+ 'vendors.json': 'Vendors',
1366
+ 'wedding_party.json': 'Wedding Party'
1367
+ }
1368
+ return friendly_names.get(filename, filename)
1369
+
1370
+ def show_google_drive_sync_status():
1371
+ """Show Google Drive sync status and push button prominently in main app"""
1372
+ config_manager = st.session_state.config_manager
1373
+ drive_status = config_manager.get_google_drive_status()
1374
+
1375
+ # Only show if Google Drive is enabled
1376
+ if not drive_status['enabled']:
1377
+ return
1378
+
1379
+ # Get modified files
1380
+ modified_files = config_manager.get_modified_files()
1381
+
1382
+ # Create a prominent status bar with always-visible push button
1383
+ col1, col2, col3 = st.columns([2, 1, 1])
1384
+
1385
+ with col1:
1386
+ if modified_files:
1387
+ friendly_names = [get_friendly_file_name(f) for f in modified_files]
1388
+ st.warning(f"📝 **{len(modified_files)} unsaved changes** - {', '.join(friendly_names)}")
1389
+ else:
1390
+ st.success("✅ **All changes saved** - Your data is up to date with Google Drive")
1391
+
1392
+ with col2:
1393
+ # Always show push button - it will sync all current data
1394
+ if modified_files:
1395
+ button_text = f"📤 Push {len(modified_files)} Changes"
1396
+ button_help = f"Save {len(modified_files)} modified files to Google Drive"
1397
+ else:
1398
+ button_text = "📤 Push to Drive"
1399
+ button_help = "Save current data to Google Drive"
1400
+
1401
+ if st.button(button_text, type="primary", help=button_help):
1402
+ with st.spinner("Pushing changes to Google Drive..."):
1403
+ if config_manager.manual_sync_to_drive():
1404
+ st.success("✅ Changes saved to Google Drive!")
1405
+ st.rerun()
1406
+ else:
1407
+ st.error("❌ Failed to save changes to Google Drive")
1408
+
1409
+ with col3:
1410
+ if st.button("🔄 Pull Latest", help="Get latest changes from Google Drive"):
1411
+ with st.spinner("Pulling latest changes from Google Drive..."):
1412
+ if config_manager.manual_sync_from_drive():
1413
+ st.success("✅ Latest changes loaded from Google Drive!")
1414
+ st.rerun()
1415
+ else:
1416
+ st.error("❌ Failed to load changes from Google Drive")
1417
+
1418
+ # Add a small separator
1419
+ st.markdown("---")
1420
+
1421
+ def show_google_drive_section():
1422
+ st.markdown("#### Google Drive Integration")
1423
+
1424
+ config_manager = st.session_state.config_manager
1425
+ drive_status = config_manager.get_google_drive_status()
1426
+
1427
+ # Status display
1428
+ if drive_status['enabled']:
1429
+ if drive_status['status'] == 'Online':
1430
+ st.success(f"✅ {drive_status['message']}")
1431
+ elif drive_status['status'] == 'Offline':
1432
+ st.warning(f"⚠️ {drive_status['message']}")
1433
+ else:
1434
+ st.error(f"❌ {drive_status['message']}")
1435
+ else:
1436
+ st.info(f"ℹ️ {drive_status['message']}")
1437
+
1438
+ # Show files if available
1439
+ if drive_status['enabled'] and 'files' in drive_status:
1440
+ st.markdown("**Files in Google Drive:**")
1441
+ for file_name in drive_status['files']:
1442
+ friendly_name = get_friendly_file_name(file_name)
1443
+ st.markdown(f"• {friendly_name}")
1444
+
1445
+ # Show modified files status
1446
+ if drive_status['enabled']:
1447
+ modified_files = config_manager.get_modified_files()
1448
+ if modified_files:
1449
+ st.markdown("#### 📝 Modified Files (Not Synced)")
1450
+ st.warning(f"The following files have been modified and need to be synced to Google Drive:")
1451
+ for file_name in modified_files:
1452
+ friendly_name = get_friendly_file_name(file_name)
1453
+ st.markdown(f"• {friendly_name}")
1454
+ else:
1455
+ st.markdown("#### ✅ All Files Synced")
1456
+ st.success("All your data is up to date with Google Drive.")
1457
+
1458
+ # Manual sync buttons
1459
+ if drive_status['enabled']:
1460
+ st.markdown("#### Manual Sync")
1461
+ col1, col2 = st.columns(2)
1462
+
1463
+ with col1:
1464
+ if st.button("📥 Sync from Google Drive", help="Download latest data from Google Drive"):
1465
+ with st.spinner("Syncing from Google Drive..."):
1466
+ if config_manager.manual_sync_from_drive():
1467
+ st.success("Successfully synced from Google Drive!")
1468
+ st.rerun()
1469
+ else:
1470
+ st.error("Failed to sync from Google Drive")
1471
+
1472
+ with col2:
1473
+ # Show different button text based on whether there are modified files
1474
+ modified_files = config_manager.get_modified_files()
1475
+ if modified_files:
1476
+ button_text = f"📤 Sync {len(modified_files)} Modified Files to Drive"
1477
+ button_help = f"Upload {len(modified_files)} modified files to Google Drive"
1478
+ else:
1479
+ button_text = "📤 Sync to Google Drive"
1480
+ button_help = "Upload current data to Google Drive (no changes to sync)"
1481
+
1482
+ if st.button(button_text, help=button_help, disabled=not modified_files):
1483
+ with st.spinner("Syncing to Google Drive..."):
1484
+ if config_manager.manual_sync_to_drive():
1485
+ st.success("Successfully synced to Google Drive!")
1486
+ st.rerun()
1487
+ else:
1488
+ st.error("Failed to sync to Google Drive")
1489
+
1490
+ # Configuration info
1491
+ st.markdown("#### Configuration")
1492
+ st.markdown("To enable Google Drive integration, set the following environment variables:")
1493
+ st.code("""
1494
+ GOOGLE_DRIVE_FOLDER_ID=your_folder_id
1495
+ GOOGLE_PROJECT_ID=your_project_id
1496
+ GOOGLE_PRIVATE_KEY_ID=your_private_key_id
1497
+ GOOGLE_PRIVATE_KEY=your_private_key
1498
+ GOOGLE_CLIENT_EMAIL=your_client_email
1499
+ GOOGLE_CLIENT_ID=your_client_id
1500
+ """)
1501
+
1502
+ st.markdown("**Note:** Google Drive integration is automatically enabled when running on Hugging Face Spaces with proper credentials configured.")
1503
+
1504
+ def show_event_management_section(config):
1505
+ st.markdown("#### Manage Wedding Events")
1506
+
1507
+ # Initialize session state for event editing if not exists
1508
+ if 'edit_events' not in st.session_state:
1509
+ st.session_state.edit_events = config.get('wedding_events', []).copy()
1510
+
1511
+ wedding_events = st.session_state.edit_events
1512
+ wedding_info = config.get('wedding_info', {})
1513
+
1514
+ # Get wedding dates for date calculations
1515
+ wedding_start_str = wedding_info.get('wedding_start_date', '')
1516
+ wedding_end_str = wedding_info.get('wedding_end_date', '')
1517
+
1518
+ if not wedding_start_str or not wedding_end_str:
1519
+ st.warning("Please set your wedding date range in the 'Edit Configuration' tab first.")
1520
+ return
1521
+
1522
+ try:
1523
+ wedding_start = datetime.fromisoformat(wedding_start_str).date()
1524
+ wedding_end = datetime.fromisoformat(wedding_end_str).date()
1525
+ except:
1526
+ st.error("Invalid wedding date format. Please check your configuration.")
1527
+ return
1528
+
1529
+ # Add event button
1530
+ if st.button("➕ Add New Event"):
1531
+ st.session_state.edit_events.append({
1532
+ "name": "New Event",
1533
+ "description": "",
1534
+ "date_offset": 0,
1535
+ "requires_meal_choice": False,
1536
+ "meal_options": [],
1537
+ "location": "",
1538
+ "address": ""
1539
+ })
1540
+ st.rerun()
1541
+
1542
+ # Display events for editing
1543
+ if st.session_state.edit_events:
1544
+ st.markdown("##### Edit Events")
1545
+
1546
+ for i, event in enumerate(st.session_state.edit_events):
1547
+ with st.expander(f"Event {i+1}: {event['name']}", expanded=True):
1548
+ # Add delete button at the top right of each event
1549
+ col_header1, col_header2 = st.columns([4, 1])
1550
+ with col_header2:
1551
+ if st.button("🗑️ Delete", key=f"delete_event_{i}", help="Delete this event"):
1552
+ st.session_state.edit_events.pop(i)
1553
+ st.rerun()
1554
+
1555
+ col1, col2 = st.columns(2)
1556
+
1557
+ with col1:
1558
+ event_name = st.text_input("Event Name", value=event['name'], key=f"settings_event_name_{i}")
1559
+ event_description = st.text_input("Time", value=event['description'], placeholder="e.g., 2:00 PM, 6:30 PM", key=f"settings_event_desc_{i}")
1560
+ event_location = st.text_input("Location Name", value=event.get('location', ''), placeholder="e.g., Central Park, Grand Ballroom", key=f"settings_event_location_{i}")
1561
+
1562
+ with col2:
1563
+ # Calculate current event date from date_offset
1564
+ current_event_date = wedding_start + timedelta(days=event['date_offset'])
1565
+
1566
+ # Use date input without constraints - allow any date
1567
+ event_date = st.date_input(
1568
+ "Event Date",
1569
+ value=current_event_date,
1570
+ key=f"settings_event_date_{i}",
1571
+ help="Select any date for this event"
1572
+ )
1573
+
1574
+ # Show warning if date is outside wedding range
1575
+ if event_date < wedding_start or event_date > wedding_end:
1576
+ st.warning(f"⚠️ Selected date is outside your wedding date range ({wedding_start.strftime('%B %d, %Y')} - {wedding_end.strftime('%B %d, %Y')})")
1577
+
1578
+ requires_meal_choice = st.checkbox("Requires Meal Choice", value=event['requires_meal_choice'], key=f"settings_event_meal_{i}")
1579
+
1580
+ # Meal options section (only show if meal choice is required)
1581
+ if requires_meal_choice:
1582
+ st.markdown("**Meal Options**")
1583
+ st.markdown("Enter meal options (one per line):")
1584
+ current_meal_options = event.get('meal_options', [])
1585
+ meal_options_text = '\n'.join(current_meal_options) if current_meal_options else ''
1586
+ 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)
1587
+ else:
1588
+ meal_options = ""
1589
+
1590
+ 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)
1591
+
1592
+ # Calculate date_offset from the selected date
1593
+ date_offset = (event_date - wedding_start).days
1594
+
1595
+ # Parse meal options
1596
+ meal_options_list = []
1597
+ if requires_meal_choice and meal_options:
1598
+ meal_options_list = [option.strip() for option in meal_options.split('\n') if option.strip()]
1599
+
1600
+ # Update session state
1601
+ st.session_state.edit_events[i] = {
1602
+ "name": event_name,
1603
+ "description": event_description,
1604
+ "date_offset": date_offset,
1605
+ "requires_meal_choice": requires_meal_choice,
1606
+ "meal_options": meal_options_list,
1607
+ "location": event_location,
1608
+ "address": event_address
1609
+ }
1610
+
1611
+ # Save events button
1612
+ st.markdown("---")
1613
+ col1, col2, col3 = st.columns([1, 1, 1])
1614
+ with col2:
1615
+ if st.button("💾 Save Event Changes", type="primary"):
1616
+ # Update the configuration with edited events
1617
+ updated_config = config.copy()
1618
+ updated_config['wedding_events'] = st.session_state.edit_events
1619
+
1620
+ # Save the updated configuration
1621
+ st.session_state.config_manager.save_config(updated_config)
1622
+ st.success("Event changes saved successfully!")
1623
+ st.rerun()
1624
+ else:
1625
+ st.info("No events added yet. Click 'Add New Event' to get started!")
1626
+
1627
+ # Event summary
1628
+ if st.session_state.edit_events:
1629
+ st.markdown("##### Event Summary")
1630
+
1631
+ col1, col2, col3 = st.columns(3)
1632
+
1633
+ with col1:
1634
+ total_events = len(st.session_state.edit_events)
1635
+ st.metric("Total Events", total_events)
1636
+
1637
+ with col2:
1638
+ meal_events = len([e for e in st.session_state.edit_events if e.get('requires_meal_choice', False)])
1639
+ st.metric("Events with Meals", meal_events)
1640
+
1641
+ with col3:
1642
+ days_span = (wedding_end - wedding_start).days + 1
1643
+ st.metric("Celebration Days", days_span)
1644
+
1645
+ if __name__ == "__main__":
1646
+ 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,789 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from datetime import datetime
4
+ from google_drive_manager import GoogleDriveManager
5
+ import streamlit as st
6
+
7
+ class ConfigManager:
8
+ def __init__(self):
9
+ self.config_file = "wedding_config.json"
10
+ self.data_dir = "data"
11
+ self.app_config_file = "app_config.json"
12
+ self.demo_data_dir = "demo_data"
13
+
14
+ # Check if running on Hugging Face Spaces
15
+ self.is_huggingface = os.getenv('SPACE_ID') is not None
16
+
17
+ # Initialize Google Drive manager
18
+ self.drive_manager = GoogleDriveManager()
19
+ self.google_drive_enabled = False
20
+
21
+ # For Hugging Face Spaces, use in-memory storage
22
+ if self.is_huggingface:
23
+ self.use_memory_storage = True
24
+ self.memory_data = {}
25
+ self.data_loaded_from_drive = False # Track if data has been loaded
26
+ else:
27
+ self.use_memory_storage = False
28
+ self.data_loaded_from_drive = False
29
+ # Set up data directory for local development
30
+ try:
31
+ if not os.path.exists(self.data_dir):
32
+ os.makedirs(self.data_dir, exist_ok=True)
33
+ if not os.path.exists(self.demo_data_dir):
34
+ os.makedirs(self.demo_data_dir, exist_ok=True)
35
+ except Exception as e:
36
+ print(f"Error creating directories: {e}")
37
+
38
+ # Load app configuration
39
+ self.app_config = self.load_app_config()
40
+
41
+ # Initialize Google Drive if enabled
42
+ self._initialize_google_drive()
43
+
44
+ def get_user_folder(self):
45
+ """Get the user-specific folder name"""
46
+ # Use demo_data folder if in demo mode, otherwise use laraandumang
47
+ if self.is_demo_mode():
48
+ return "demo_data"
49
+ return "laraandumang"
50
+
51
+ def set_user_folder(self, folder_name):
52
+ """Set the user-specific folder name"""
53
+ self.user_folder = folder_name
54
+
55
+ def get_current_user_folder(self):
56
+ """Get the currently set user folder"""
57
+ return getattr(self, 'user_folder', self.get_user_folder())
58
+
59
+ def _initialize_google_drive(self):
60
+ """Initialize Google Drive connection"""
61
+ try:
62
+ folder_id = os.getenv('GOOGLE_DRIVE_FOLDER_ID')
63
+ if folder_id:
64
+ if self.drive_manager.initialize(folder_id):
65
+ self.google_drive_enabled = True
66
+ # Don't automatically sync on startup - let user choose
67
+ print("Google Drive initialized successfully. Manual sync required.")
68
+ except Exception as e:
69
+ print(f"Google Drive initialization failed: {e}")
70
+ self.google_drive_enabled = False
71
+
72
+ def _sync_from_google_drive(self):
73
+ """Sync data files from Google Drive to local storage"""
74
+ if not self.google_drive_enabled:
75
+ return
76
+
77
+ try:
78
+ # Get user-specific folder
79
+ user_folder = self.get_current_user_folder()
80
+
81
+ # List of data files to sync
82
+ data_files = [
83
+ 'guest_list_data.json',
84
+ 'rsvp_data.json',
85
+ 'tasks.json',
86
+ 'vendors.json',
87
+ 'wedding_party.json'
88
+ ]
89
+
90
+ # Sync config file from user folder
91
+ config_content = self.drive_manager.download_file(f'{user_folder}/wedding_config.json')
92
+ if config_content:
93
+ if self.use_memory_storage:
94
+ # Store in memory for Hugging Face Spaces
95
+ self.memory_data['wedding_config.json'] = config_content
96
+ # Update cache in session state
97
+ st.session_state['cached_wedding_config'] = config_content
98
+ print("✅ Loaded wedding configuration from Google Drive")
99
+ else:
100
+ # Store in file for local development
101
+ config_path = self.get_config_file_path()
102
+ config_dir = os.path.dirname(config_path)
103
+ if config_dir:
104
+ os.makedirs(config_dir, exist_ok=True)
105
+ with open(config_path, 'w') as f:
106
+ json.dump(config_content, f, indent=2)
107
+ # Update cache in session state
108
+ st.session_state['cached_wedding_config'] = config_content
109
+ print(f"Stored wedding_config.json in file: {config_path}")
110
+
111
+ # Sync data files from user folder
112
+ for file_name in data_files:
113
+ content = self.drive_manager.download_file(f'{user_folder}/{file_name}')
114
+ if content:
115
+ if self.use_memory_storage:
116
+ # Store in memory for Hugging Face Spaces
117
+ self.memory_data[file_name] = content
118
+ # Update cache in session state
119
+ cache_key = f"cached_{file_name}"
120
+ st.session_state[cache_key] = content
121
+ # Only print once per sync operation
122
+ if file_name == data_files[0]: # First file
123
+ print(f"✅ Loaded {len(data_files)} data files from Google Drive")
124
+ else:
125
+ # Store in file for local development
126
+ file_path = self.get_data_file_path(file_name)
127
+ file_dir = os.path.dirname(file_path)
128
+ if file_dir:
129
+ os.makedirs(file_dir, exist_ok=True)
130
+ with open(file_path, 'w') as f:
131
+ json.dump(content, f, indent=2)
132
+ # Update cache in session state
133
+ cache_key = f"cached_{file_name}"
134
+ st.session_state[cache_key] = content
135
+ print(f"Stored {file_name} in file: {file_path}")
136
+ except Exception as e:
137
+ print(f"Error syncing from Google Drive: {e}")
138
+ # Don't fail the entire initialization if sync fails
139
+ # This is expected behavior - user can manually sync later
140
+
141
+ def _sync_to_google_drive(self):
142
+ """Sync local data files to Google Drive"""
143
+ if not self.google_drive_enabled:
144
+ return
145
+
146
+ # Get user-specific folder
147
+ user_folder = self.get_user_folder()
148
+
149
+ # Sync config file to user folder
150
+ config_path = self.get_config_file_path()
151
+ if os.path.exists(config_path):
152
+ with open(config_path, 'r') as f:
153
+ config_content = json.load(f)
154
+ self.drive_manager.upload_file(f'{user_folder}/wedding_config.json', config_content)
155
+
156
+ # Sync data files to user folder
157
+ data_files = [
158
+ 'guest_list_data.json',
159
+ 'rsvp_data.json',
160
+ 'tasks.json',
161
+ 'vendors.json',
162
+ 'wedding_party.json'
163
+ ]
164
+
165
+ for file_name in data_files:
166
+ file_path = self.get_data_file_path(file_name)
167
+ if os.path.exists(file_path):
168
+ with open(file_path, 'r') as f:
169
+ content = json.load(f)
170
+ self.drive_manager.upload_file(f'{user_folder}/{file_name}', content)
171
+
172
+ def load_app_config(self):
173
+ """Load app configuration from file"""
174
+ # For Hugging Face Spaces, check /tmp directory first
175
+ if self.is_huggingface:
176
+ tmp_config_path = f"/tmp/{self.app_config_file}"
177
+ if os.path.exists(tmp_config_path):
178
+ try:
179
+ with open(tmp_config_path, 'r') as f:
180
+ return json.load(f)
181
+ except (json.JSONDecodeError, FileNotFoundError):
182
+ pass
183
+
184
+ # Check original location
185
+ if os.path.exists(self.app_config_file):
186
+ try:
187
+ with open(self.app_config_file, 'r') as f:
188
+ return json.load(f)
189
+ except (json.JSONDecodeError, FileNotFoundError):
190
+ return self.get_default_app_config()
191
+ return self.get_default_app_config()
192
+
193
+ def get_default_app_config(self):
194
+ """Get default app configuration"""
195
+ return {
196
+ "demo_mode": False,
197
+ "demo_data_path": "demo_data",
198
+ "app_settings": {
199
+ "theme": "green_adirondack",
200
+ "auto_save": True,
201
+ "backup_enabled": True
202
+ }
203
+ }
204
+
205
+ def is_demo_mode(self):
206
+ """Check if demo mode is enabled"""
207
+ return self.app_config.get("demo_mode", False)
208
+
209
+ def get_data_directory(self):
210
+ """Get the appropriate data directory based on demo mode"""
211
+ if self.is_demo_mode():
212
+ return self.demo_data_dir
213
+ return self.data_dir
214
+
215
+ def get_config_file_path(self):
216
+ """Get the appropriate config file path based on demo mode"""
217
+ if self.is_demo_mode():
218
+ return os.path.join(self.demo_data_dir, "wedding_config.json")
219
+
220
+ # For Hugging Face Spaces, use app directory to avoid permission issues
221
+ if self.is_huggingface:
222
+ return "wedding_config.json"
223
+
224
+ # For local development, use the original path
225
+ if os.path.isabs(self.config_file):
226
+ return self.config_file
227
+ else:
228
+ return os.path.join(os.getcwd(), self.config_file)
229
+
230
+ def config_exists(self):
231
+ """Check if configuration file exists"""
232
+ if self.use_memory_storage:
233
+ return 'wedding_config.json' in self.memory_data
234
+ else:
235
+ config_path = self.get_config_file_path()
236
+ return os.path.exists(config_path)
237
+
238
+ def load_config(self):
239
+ """Load configuration from cache, file or memory"""
240
+ # Check if we have cached config in session state
241
+ if 'cached_wedding_config' in st.session_state:
242
+ return st.session_state['cached_wedding_config']
243
+
244
+ if self.config_exists():
245
+ try:
246
+ if self.use_memory_storage:
247
+ config = self.memory_data['wedding_config.json']
248
+ else:
249
+ config_path = self.get_config_file_path()
250
+ with open(config_path, 'r') as f:
251
+ config = json.load(f)
252
+
253
+ # Cache the config in session state
254
+ st.session_state['cached_wedding_config'] = config
255
+ return config
256
+ except (json.JSONDecodeError, FileNotFoundError, KeyError):
257
+ default_config = self.get_default_config()
258
+ st.session_state['cached_wedding_config'] = default_config
259
+ return default_config
260
+
261
+ default_config = self.get_default_config()
262
+ st.session_state['cached_wedding_config'] = default_config
263
+ return default_config
264
+
265
+ def save_config(self, config):
266
+ """Save configuration to file or memory"""
267
+ try:
268
+ if self.use_memory_storage:
269
+ # Store in memory for Hugging Face Spaces
270
+ self.memory_data['wedding_config.json'] = config
271
+ else:
272
+ # Store in file for local development
273
+ config_path = self.get_config_file_path()
274
+ with open(config_path, 'w') as f:
275
+ json.dump(config, f, indent=2)
276
+
277
+ # Update cache in session state
278
+ st.session_state['cached_wedding_config'] = config
279
+
280
+ # Mark config as modified for manual sync
281
+ st.session_state['config_modified'] = True
282
+
283
+ return True
284
+ except Exception as e:
285
+ print(f"Error saving config: {e}")
286
+ return False
287
+
288
+ def reset_config(self):
289
+ """Reset configuration by deleting the config file"""
290
+ try:
291
+ config_path = self.get_config_file_path()
292
+ if os.path.exists(config_path):
293
+ os.remove(config_path)
294
+ return True
295
+ except Exception as e:
296
+ print(f"Error resetting config: {e}")
297
+ return False
298
+
299
+ def get_default_config(self):
300
+ """Get default configuration"""
301
+ return {
302
+ 'wedding_info': {
303
+ 'partner1_name': '',
304
+ 'partner2_name': '',
305
+ 'wedding_start_date': '',
306
+ 'wedding_end_date': '',
307
+ 'venue_city': ''
308
+ },
309
+ 'custom_settings': {
310
+ 'custom_tags': [
311
+ "Wedding Party", "Urgent", "Rehearsal", "Timeline",
312
+ "Maid of Honor", "Best Man", "Bridesmaid", "Groomsman",
313
+ "Flower Girl", "Ring Bearer", "Usher", "Reader",
314
+ "Venue", "Catering", "Photography", "Videography", "Music/DJ",
315
+ "Flowers", "Decor", "Attire", "Hair & Makeup", "Transportation",
316
+ "Invitations", "Cake", "Officiant", "Other",
317
+ "Decorations", "Centerpieces", "Favors", "Signage", "Linens",
318
+ "Tableware", "Lighting", "Accessories", "Stationery", "Gifts",
319
+ "Vendor", "Item", "Deposit Required", "Final Payment", "Installment",
320
+ "Food & Beverage", "Music", "Entertainment", "Lodging"
321
+ ],
322
+ 'task_assignees': []
323
+ },
324
+ 'wedding_events': [
325
+ {"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": ""},
326
+ {"name": "Church Ceremony", "date_offset": 0, "description": "Main wedding ceremony", "requires_meal_choice": False, "meal_options": [], "location": ""},
327
+ {"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": ""},
328
+ {"name": "Mehndi Afterparty", "date_offset": 1, "description": "Mehndi celebration and afterparty", "requires_meal_choice": False, "meal_options": [], "location": ""},
329
+ {"name": "Indian Ceremony", "date_offset": 1, "description": "Traditional Indian wedding ceremony", "requires_meal_choice": False, "meal_options": [], "location": ""},
330
+ {"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": ""}
331
+ ]
332
+ }
333
+
334
+ def get_data_file_path(self, filename):
335
+ """Get full path for data files"""
336
+ data_dir = self.get_data_directory()
337
+ return os.path.join(data_dir, filename)
338
+
339
+ def load_json_data(self, filename):
340
+ """Load JSON data from cache, data directory, or memory"""
341
+ # Check if we have cached data in session state
342
+ cache_key = f"cached_{filename}"
343
+ if cache_key in st.session_state:
344
+ # Data loaded from cache
345
+ return st.session_state[cache_key]
346
+
347
+ # Load data from source
348
+ if self.use_memory_storage:
349
+ # Load from memory for Hugging Face Spaces
350
+ data = self.memory_data.get(filename, [])
351
+ else:
352
+ # Load from file for local development
353
+ filepath = self.get_data_file_path(filename)
354
+ if os.path.exists(filepath):
355
+ try:
356
+ with open(filepath, 'r') as f:
357
+ data = json.load(f)
358
+ except (json.JSONDecodeError, FileNotFoundError):
359
+ data = []
360
+ else:
361
+ data = []
362
+
363
+ # Cache the data in session state
364
+ st.session_state[cache_key] = data
365
+ return data
366
+
367
+ def save_json_data(self, filename, data):
368
+ """Save JSON data to data directory or memory"""
369
+ try:
370
+ if self.use_memory_storage:
371
+ # Store in memory for Hugging Face Spaces
372
+ self.memory_data[filename] = data
373
+ else:
374
+ # Store in file for local development
375
+ filepath = self.get_data_file_path(filename)
376
+ with open(filepath, 'w') as f:
377
+ json.dump(data, f, indent=2)
378
+
379
+ # Update cache in session state
380
+ cache_key = f"cached_{filename}"
381
+ st.session_state[cache_key] = data
382
+
383
+ # Mark data as modified for manual sync
384
+ st.session_state[f'{filename}_modified'] = True
385
+
386
+ return True
387
+ except Exception as e:
388
+ print(f"Error saving data to {filename}: {e}")
389
+ return False
390
+
391
+ def toggle_demo_mode(self):
392
+ """Toggle demo mode on/off"""
393
+ self.app_config["demo_mode"] = not self.app_config.get("demo_mode", False)
394
+ try:
395
+ # For Hugging Face Spaces, use /tmp directory to avoid permission issues
396
+ if self.is_huggingface:
397
+ config_path = f"/tmp/{self.app_config_file}"
398
+ else:
399
+ config_path = self.app_config_file
400
+
401
+ with open(config_path, 'w') as f:
402
+ json.dump(self.app_config, f, indent=2)
403
+ return True
404
+ except Exception as e:
405
+ print(f"Error saving app config: {e}")
406
+ return False
407
+
408
+ def set_demo_mode(self, enabled):
409
+ """Set demo mode to specific value"""
410
+ self.app_config["demo_mode"] = enabled
411
+ try:
412
+ # For Hugging Face Spaces, use /tmp directory to avoid permission issues
413
+ if self.is_huggingface:
414
+ config_path = f"/tmp/{self.app_config_file}"
415
+ else:
416
+ config_path = self.app_config_file
417
+
418
+ with open(config_path, 'w') as f:
419
+ json.dump(self.app_config, f, indent=2)
420
+ return True
421
+ except Exception as e:
422
+ print(f"Error saving app config: {e}")
423
+ return False
424
+
425
+ def is_google_drive_enabled(self):
426
+ """Check if Google Drive integration is enabled and working"""
427
+ return self.google_drive_enabled and self.drive_manager.is_online()
428
+
429
+ def manual_sync_to_drive(self):
430
+ """Manually sync all data to Google Drive"""
431
+ if not self.google_drive_enabled:
432
+ return False
433
+
434
+ try:
435
+ self._sync_to_google_drive()
436
+ return True
437
+ except Exception as e:
438
+ print(f"Error syncing to Google Drive: {e}")
439
+ return False
440
+
441
+ def clear_cache(self):
442
+ """Clear all cached data from session state"""
443
+ data_files = [
444
+ 'guest_list_data.json',
445
+ 'rsvp_data.json',
446
+ 'tasks.json',
447
+ 'vendors.json',
448
+ 'wedding_party.json',
449
+ 'guests.json'
450
+ ]
451
+
452
+ for filename in data_files:
453
+ cache_key = f"cached_{filename}"
454
+ if cache_key in st.session_state:
455
+ del st.session_state[cache_key]
456
+
457
+ # Also clear config cache
458
+ if 'cached_wedding_config' in st.session_state:
459
+ del st.session_state['cached_wedding_config']
460
+
461
+ def get_cache_status(self):
462
+ """Get information about what data is currently cached"""
463
+ data_files = [
464
+ 'guest_list_data.json',
465
+ 'rsvp_data.json',
466
+ 'tasks.json',
467
+ 'vendors.json',
468
+ 'wedding_party.json',
469
+ 'guests.json'
470
+ ]
471
+
472
+ cached_files = []
473
+ for filename in data_files:
474
+ cache_key = f"cached_{filename}"
475
+ if cache_key in st.session_state:
476
+ cached_files.append(filename)
477
+
478
+ config_cached = 'cached_wedding_config' in st.session_state
479
+
480
+ return {
481
+ 'cached_data_files': cached_files,
482
+ 'config_cached': config_cached,
483
+ 'total_cached': len(cached_files) + (1 if config_cached else 0)
484
+ }
485
+
486
+ def invalidate_cache_for_file(self, filename):
487
+ """Invalidate cache for a specific file"""
488
+ cache_key = f"cached_{filename}"
489
+ if cache_key in st.session_state:
490
+ del st.session_state[cache_key]
491
+
492
+ def invalidate_config_cache(self):
493
+ """Invalidate the config cache"""
494
+ if 'cached_wedding_config' in st.session_state:
495
+ del st.session_state['cached_wedding_config']
496
+
497
+ def manual_sync_from_drive(self):
498
+ """Manually sync all data from Google Drive"""
499
+ if not self.google_drive_enabled:
500
+ return False
501
+
502
+ try:
503
+ self._sync_from_google_drive()
504
+ self.data_loaded_from_drive = True
505
+ # Clear modified flags since data is now in sync with Google Drive
506
+ self.clear_modified_flags()
507
+ return True
508
+ except Exception as e:
509
+ print(f"Error syncing from Google Drive: {e}")
510
+ return False
511
+
512
+ def load_existing_data_from_drive(self):
513
+ """Load existing wedding data from Google Drive and create local config"""
514
+ if not self.google_drive_enabled:
515
+ return False
516
+
517
+ try:
518
+ # Disable demo mode when loading real data
519
+ self.set_demo_mode(False)
520
+
521
+ # Get user-specific folder
522
+ user_folder = self.get_current_user_folder()
523
+
524
+ # Check if wedding_config.json exists in Google Drive user folder
525
+ config_content = self.drive_manager.download_file(f'{user_folder}/wedding_config.json')
526
+ if config_content:
527
+ # Save config locally (in memory or file)
528
+ if self.use_memory_storage:
529
+ self.memory_data['wedding_config.json'] = config_content
530
+ # Update cache in session state
531
+ st.session_state['cached_wedding_config'] = config_content
532
+ print("Stored wedding_config.json in memory")
533
+ else:
534
+ config_path = self.get_config_file_path()
535
+ config_dir = os.path.dirname(config_path)
536
+ if config_dir:
537
+ os.makedirs(config_dir, exist_ok=True)
538
+ with open(config_path, 'w') as f:
539
+ json.dump(config_content, f, indent=2)
540
+ # Update cache in session state
541
+ st.session_state['cached_wedding_config'] = config_content
542
+ print(f"Stored wedding_config.json in file: {config_path}")
543
+
544
+ # Load data files from user folder
545
+ data_files = [
546
+ 'guest_list_data.json',
547
+ 'rsvp_data.json',
548
+ 'tasks.json',
549
+ 'vendors.json',
550
+ 'wedding_party.json'
551
+ ]
552
+
553
+ for file_name in data_files:
554
+ content = self.drive_manager.download_file(f'{user_folder}/{file_name}')
555
+ if content:
556
+ if self.use_memory_storage:
557
+ self.memory_data[file_name] = content
558
+ # Update cache in session state
559
+ cache_key = f"cached_{file_name}"
560
+ st.session_state[cache_key] = content
561
+ print(f"Stored {file_name} in memory")
562
+ else:
563
+ file_path = self.get_data_file_path(file_name)
564
+ file_dir = os.path.dirname(file_path)
565
+ if file_dir:
566
+ os.makedirs(file_dir, exist_ok=True)
567
+ with open(file_path, 'w') as f:
568
+ json.dump(content, f, indent=2)
569
+ # Update cache in session state
570
+ cache_key = f"cached_{file_name}"
571
+ st.session_state[cache_key] = content
572
+ print(f"Stored {file_name} in file: {file_path}")
573
+
574
+ self.data_loaded_from_drive = True
575
+ return True
576
+ return False
577
+ except Exception as e:
578
+ print(f"Error loading existing data from Google Drive: {e}")
579
+ # This is a common issue on Hugging Face Spaces due to network/SSL issues
580
+ # The error is expected and the user can retry manually
581
+ return False
582
+
583
+ def load_demo_data_from_drive(self):
584
+ """Load demo wedding data from Google Drive and enable demo mode"""
585
+ if not self.google_drive_enabled:
586
+ return False
587
+
588
+ try:
589
+ # Enable demo mode first
590
+ self.set_demo_mode(True)
591
+
592
+ # Load demo data from demo folder in Google Drive
593
+ demo_folder = self.get_current_user_folder() # Will return "demo_data" since demo mode is enabled
594
+
595
+ # Check if wedding_config.json exists in demo folder
596
+ config_content = self.drive_manager.download_file(f'{demo_folder}/wedding_config.json')
597
+ if config_content:
598
+ # Save config locally (in memory or file)
599
+ if self.use_memory_storage:
600
+ self.memory_data['wedding_config.json'] = config_content
601
+ # Update cache in session state
602
+ st.session_state['cached_wedding_config'] = config_content
603
+ print("Stored demo wedding_config.json in memory")
604
+ else:
605
+ config_path = self.get_config_file_path()
606
+ config_dir = os.path.dirname(config_path)
607
+ if config_dir:
608
+ os.makedirs(config_dir, exist_ok=True)
609
+ with open(config_path, 'w') as f:
610
+ json.dump(config_content, f, indent=2)
611
+ # Update cache in session state
612
+ st.session_state['cached_wedding_config'] = config_content
613
+ print(f"Stored demo wedding_config.json in file: {config_path}")
614
+
615
+ # Load demo data files from demo folder
616
+ data_files = [
617
+ 'guest_list_data.json',
618
+ 'rsvp_data.json',
619
+ 'tasks.json',
620
+ 'vendors.json',
621
+ 'wedding_party.json'
622
+ ]
623
+
624
+ for file_name in data_files:
625
+ content = self.drive_manager.download_file(f'{demo_folder}/{file_name}')
626
+ if content:
627
+ if self.use_memory_storage:
628
+ self.memory_data[file_name] = content
629
+ # Update cache in session state
630
+ cache_key = f"cached_{file_name}"
631
+ st.session_state[cache_key] = content
632
+ print(f"Stored demo {file_name} in memory")
633
+ else:
634
+ file_path = self.get_data_file_path(file_name)
635
+ file_dir = os.path.dirname(file_path)
636
+ if file_dir:
637
+ os.makedirs(file_dir, exist_ok=True)
638
+ with open(file_path, 'w') as f:
639
+ json.dump(content, f, indent=2)
640
+ # Update cache in session state
641
+ cache_key = f"cached_{file_name}"
642
+ st.session_state[cache_key] = content
643
+ print(f"Stored demo {file_name} in file: {file_path}")
644
+
645
+ self.data_loaded_from_drive = True
646
+ return True
647
+ return False
648
+ except Exception as e:
649
+ print(f"Error loading demo data from Google Drive: {e}")
650
+ # This is a common issue on Hugging Face Spaces due to network/SSL issues
651
+ # The error is expected and the user can retry manually
652
+ return False
653
+
654
+ def get_google_drive_status(self):
655
+ """Get status information about Google Drive integration"""
656
+ if not self.google_drive_enabled:
657
+ return {
658
+ 'enabled': False,
659
+ 'status': 'Disabled',
660
+ 'message': 'Google Drive integration not configured'
661
+ }
662
+
663
+ if not self.drive_manager.is_online():
664
+ return {
665
+ 'enabled': True,
666
+ 'status': 'Offline',
667
+ 'message': 'Google Drive service unavailable'
668
+ }
669
+
670
+ try:
671
+ files = self.drive_manager.list_files()
672
+ return {
673
+ 'enabled': True,
674
+ 'status': 'Online',
675
+ 'message': f'Connected to Google Drive ({len(files)} files found)',
676
+ 'files': [f['name'] for f in files]
677
+ }
678
+ except Exception as e:
679
+ return {
680
+ 'enabled': True,
681
+ 'status': 'Error',
682
+ 'message': f'Error connecting to Google Drive: {str(e)}'
683
+ }
684
+
685
+ def get_modified_files(self):
686
+ """Get list of files that have been modified since last sync"""
687
+ modified_files = []
688
+
689
+ # Check config
690
+ if st.session_state.get('config_modified', False):
691
+ modified_files.append('wedding_config.json')
692
+
693
+ # Check data files
694
+ data_files = [
695
+ 'guest_list_data.json',
696
+ 'rsvp_data.json',
697
+ 'tasks.json',
698
+ 'vendors.json',
699
+ 'wedding_party.json'
700
+ ]
701
+
702
+ for filename in data_files:
703
+ if st.session_state.get(f'{filename}_modified', False):
704
+ modified_files.append(filename)
705
+
706
+ return modified_files
707
+
708
+ def clear_modified_flags(self):
709
+ """Clear all modified flags after successful sync"""
710
+ st.session_state['config_modified'] = False
711
+
712
+ data_files = [
713
+ 'guest_list_data.json',
714
+ 'rsvp_data.json',
715
+ 'tasks.json',
716
+ 'vendors.json',
717
+ 'wedding_party.json'
718
+ ]
719
+
720
+ for filename in data_files:
721
+ st.session_state[f'{filename}_modified'] = False
722
+
723
+ def manual_sync_to_drive(self):
724
+ """Manually sync all current data to Google Drive"""
725
+ if not self.google_drive_enabled:
726
+ return False
727
+
728
+ try:
729
+ # Get user-specific folder
730
+ user_folder = self.get_current_user_folder()
731
+
732
+ modified_files = self.get_modified_files()
733
+
734
+ if not modified_files:
735
+ print("No modified files detected, but syncing all current data to ensure consistency")
736
+ else:
737
+ print(f"Syncing {len(modified_files)} modified files to Google Drive: {modified_files}")
738
+
739
+ # Always sync config (current state) to user folder
740
+ config = self.load_config()
741
+ if not self.drive_manager.upload_file(f'{user_folder}/wedding_config.json', config):
742
+ return False
743
+
744
+ # Always sync all data files (current state) to user folder
745
+ data_files = [
746
+ 'guest_list_data.json',
747
+ 'rsvp_data.json',
748
+ 'tasks.json',
749
+ 'vendors.json',
750
+ 'wedding_party.json'
751
+ ]
752
+
753
+ for filename in data_files:
754
+ data = self.load_json_data(filename)
755
+ if not self.drive_manager.upload_file(f'{user_folder}/{filename}', data):
756
+ return False
757
+
758
+ # Clear modified flags after successful sync
759
+ self.clear_modified_flags()
760
+ return True
761
+
762
+ except Exception as e:
763
+ print(f"Error syncing to Google Drive: {e}")
764
+ return False
765
+
766
+ def reset_app_state(self):
767
+ """Reset the app state by clearing all cached data and memory storage"""
768
+ try:
769
+ # Clear memory data
770
+ if hasattr(self, 'memory_data'):
771
+ self.memory_data.clear()
772
+
773
+ # Clear session state cache
774
+ if 'cached_wedding_config' in st.session_state:
775
+ del st.session_state['cached_wedding_config']
776
+
777
+ # Clear other cached data files
778
+ for key in list(st.session_state.keys()):
779
+ if key.startswith('cached_'):
780
+ del st.session_state[key]
781
+
782
+ # Reset data loaded flag
783
+ self.data_loaded_from_drive = False
784
+
785
+ print("App state reset successfully")
786
+ return True
787
+ except Exception as e:
788
+ print(f"Error resetting app state: {e}")
789
+ return False
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; color: white;'>{days_until}</h1>", unsafe_allow_html=True)
123
+ if days_until == 0:
124
+ st.markdown("<p style='margin: 0; color: white;'>Festivities begin today! 🎉</p>", unsafe_allow_html=True)
125
+ elif days_until == 1:
126
+ st.markdown("<p style='margin: 0; color: white;'>Festivities begin tomorrow! 🎊</p>", unsafe_allow_html=True)
127
+ elif days_until <= 7:
128
+ st.markdown("<p style='margin: 0; color: white;'>Festivities begin this week! ⚡</p>", unsafe_allow_html=True)
129
+ else:
130
+ st.markdown("<p style='margin: 0; color: white;'>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; color: white;'>{days_remaining}</h1>", unsafe_allow_html=True)
134
+ st.markdown("<p style='margin: 0; color: white;'>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; color: white;'>{days_since}</h1>", unsafe_allow_html=True)
138
+ st.markdown("<p style='margin: 0; color: white;'>days since festivities ended</p>", unsafe_allow_html=True)
139
+ except:
140
+ st.markdown("<h1 style='margin: 0; color: white;'>-</h1>", unsafe_allow_html=True)
141
+ st.markdown("<p style='margin: 0; color: white;'>Dates not set</p>", unsafe_allow_html=True)
142
+ else:
143
+ st.markdown("<h1 style='margin: 0; color: white;'>-</h1>", unsafe_allow_html=True)
144
+ st.markdown("<p style='margin: 0; color: white;'>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; color: white;'>{confirmed_guests}</h1>", unsafe_allow_html=True)
170
+ st.markdown("<p style='margin: 0; color: white;'>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; color: white;'>{percentage}%</h1>", unsafe_allow_html=True)
187
+ st.markdown(f"<p style='margin: 0; color: white;'>{completed}/{total} completed</p>", unsafe_allow_html=True)
188
+ else:
189
+ st.markdown("<h1 style='margin: 0; color: white;'>0%</h1>", unsafe_allow_html=True)
190
+ st.markdown("<p style='margin: 0; color: white;'>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; color: white;'>${total_actualized_cost:,.0f}</h1>", unsafe_allow_html=True)
224
+ st.markdown(f"<p style='margin: 0; color: white;'>${total_estimated_cost:,.0f} estimated total</p>", unsafe_allow_html=True)
225
+ st.markdown(f"<p style='margin: 0; color: white;'>${total_paid:,.0f} paid</p>", unsafe_allow_html=True)
226
+ st.markdown(f"<p style='margin: 0; color: white;'>${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,332 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import tempfile
4
+ import time
5
+ from typing import Dict, List, Optional, Any
6
+ from google.oauth2 import service_account
7
+ from google.oauth2.credentials import Credentials
8
+ from googleapiclient.discovery import build
9
+ from googleapiclient.errors import HttpError
10
+ import streamlit as st
11
+ import ssl
12
+
13
+ def retry_on_ssl_error(max_retries=3, delay=1):
14
+ """Decorator to retry functions on SSL errors"""
15
+ def decorator(func):
16
+ def wrapper(*args, **kwargs):
17
+ for attempt in range(max_retries):
18
+ try:
19
+ return func(*args, **kwargs)
20
+ except (ssl.SSLError, ConnectionError, OSError) as e:
21
+ if attempt == max_retries - 1:
22
+ print(f"SSL/Connection error after {max_retries} attempts: {e}")
23
+ raise
24
+ print(f"SSL/Connection error (attempt {attempt + 1}/{max_retries}): {e}")
25
+ time.sleep(delay * (attempt + 1)) # Exponential backoff
26
+ except Exception as e:
27
+ # Don't retry on other types of errors
28
+ raise
29
+ return None
30
+ return wrapper
31
+ return decorator
32
+
33
+ class GoogleDriveManager:
34
+ def __init__(self):
35
+ self.service = None
36
+ self.folder_id = None
37
+ self.is_huggingface = os.getenv('SPACE_ID') is not None
38
+ self.temp_dir = "/tmp/wedding_data" if self.is_huggingface else "temp_data"
39
+
40
+ # Ensure temp directory exists
41
+ os.makedirs(self.temp_dir, exist_ok=True)
42
+
43
+ def initialize(self, folder_id: str = None):
44
+ """Initialize Google Drive service and set folder ID"""
45
+ try:
46
+ if self.is_huggingface:
47
+ self._setup_huggingface_auth()
48
+ else:
49
+ self._setup_local_auth()
50
+
51
+ if folder_id:
52
+ self.folder_id = folder_id
53
+ else:
54
+ # Try to get folder ID from environment
55
+ self.folder_id = os.getenv('GOOGLE_DRIVE_FOLDER_ID')
56
+
57
+ if not self.folder_id:
58
+ st.error("Google Drive folder ID not found. Please set GOOGLE_DRIVE_FOLDER_ID environment variable.")
59
+ return False
60
+
61
+ return True
62
+ except Exception as e:
63
+ st.error(f"Failed to initialize Google Drive: {str(e)}")
64
+ return False
65
+
66
+ def _setup_huggingface_auth(self):
67
+ """Set up authentication for Hugging Face Spaces"""
68
+ # For Hugging Face, we'll use service account credentials
69
+ # stored as environment variables
70
+ service_account_info = {
71
+ "type": "service_account",
72
+ "project_id": os.getenv('GOOGLE_PROJECT_ID'),
73
+ "private_key_id": os.getenv('GOOGLE_PRIVATE_KEY_ID'),
74
+ "private_key": os.getenv('GOOGLE_PRIVATE_KEY', '').replace('\\n', '\n'),
75
+ "client_email": os.getenv('GOOGLE_CLIENT_EMAIL'),
76
+ "client_id": os.getenv('GOOGLE_CLIENT_ID'),
77
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
78
+ "token_uri": "https://oauth2.googleapis.com/token",
79
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
80
+ "client_x509_cert_url": f"https://www.googleapis.com/robot/v1/metadata/x509/{os.getenv('GOOGLE_CLIENT_EMAIL')}"
81
+ }
82
+
83
+ # Validate that all required fields are present
84
+ required_fields = ['project_id', 'private_key_id', 'private_key', 'client_email', 'client_id']
85
+ missing_fields = [field for field in required_fields if not service_account_info.get(field)]
86
+
87
+ if missing_fields:
88
+ raise ValueError(f"Missing Google service account credentials: {missing_fields}")
89
+
90
+ credentials = service_account.Credentials.from_service_account_info(
91
+ service_account_info,
92
+ scopes=['https://www.googleapis.com/auth/drive']
93
+ )
94
+
95
+ self.service = build('drive', 'v3', credentials=credentials)
96
+
97
+ def _setup_local_auth(self):
98
+ """Set up authentication for local development"""
99
+ # For local development, you can use OAuth or service account
100
+ # This is a simplified version - you might want to implement OAuth flow
101
+ service_account_path = os.getenv('GOOGLE_SERVICE_ACCOUNT_PATH')
102
+
103
+ if service_account_path and os.path.exists(service_account_path):
104
+ credentials = service_account.Credentials.from_service_account_file(
105
+ service_account_path,
106
+ scopes=['https://www.googleapis.com/auth/drive']
107
+ )
108
+ self.service = build('drive', 'v3', credentials=credentials)
109
+ else:
110
+ st.warning("Google service account file not found. Using local data only.")
111
+ self.service = None
112
+
113
+ @retry_on_ssl_error(max_retries=3, delay=1)
114
+ def list_files(self) -> List[Dict[str, Any]]:
115
+ """List all files in the Google Drive folder"""
116
+ if not self.service or not self.folder_id:
117
+ return []
118
+
119
+ try:
120
+ query = f"'{self.folder_id}' in parents and trashed=false"
121
+ results = self.service.files().list(
122
+ q=query,
123
+ fields="files(id, name, modifiedTime, size)"
124
+ ).execute()
125
+
126
+ return results.get('files', [])
127
+ except HttpError as e:
128
+ st.error(f"Error listing files: {str(e)}")
129
+ return []
130
+
131
+ @retry_on_ssl_error(max_retries=3, delay=1)
132
+ def download_file(self, file_name: str) -> Optional[Dict[str, Any]]:
133
+ """Download a file from Google Drive and return its content"""
134
+ if not self.service or not self.folder_id:
135
+ return None
136
+
137
+ try:
138
+ # Handle subfolder paths like 'laraandumang/wedding_config.json'
139
+ if '/' in file_name:
140
+ folder_name, actual_file_name = file_name.split('/', 1)
141
+
142
+ # First, find the subfolder
143
+ folder_query = f"name='{folder_name}' and '{self.folder_id}' in parents and trashed=false and mimeType='application/vnd.google-apps.folder'"
144
+ folder_results = self.service.files().list(q=folder_query).execute()
145
+ folders = folder_results.get('files', [])
146
+
147
+ if not folders:
148
+ st.warning(f"File '{file_name}' not found in Google Drive")
149
+ return None
150
+
151
+ folder_id = folders[0]['id']
152
+
153
+ # Now search for the file within that folder
154
+ file_query = f"name='{actual_file_name}' and '{folder_id}' in parents and trashed=false"
155
+ results = self.service.files().list(q=file_query).execute()
156
+ files = results.get('files', [])
157
+
158
+ if not files:
159
+ st.warning(f"File '{file_name}' not found in Google Drive")
160
+ return None
161
+ else:
162
+ # Direct file search in root folder
163
+ query = f"name='{file_name}' and '{self.folder_id}' in parents and trashed=false"
164
+ results = self.service.files().list(q=query).execute()
165
+ files = results.get('files', [])
166
+
167
+ if not files:
168
+ st.warning(f"File '{file_name}' not found in Google Drive")
169
+ return None
170
+
171
+ file_id = files[0]['id']
172
+
173
+ # Download file content
174
+ request = self.service.files().get_media(fileId=file_id)
175
+ content = request.execute()
176
+
177
+ # Try to parse as JSON
178
+ try:
179
+ return json.loads(content.decode('utf-8'))
180
+ except json.JSONDecodeError:
181
+ # If not JSON, return as string
182
+ return content.decode('utf-8')
183
+
184
+ except HttpError as e:
185
+ st.error(f"Error downloading file '{file_name}': {str(e)}")
186
+ return None
187
+
188
+ @retry_on_ssl_error(max_retries=3, delay=1)
189
+ def upload_file(self, file_name: str, content: Any) -> bool:
190
+ """Upload a file to Google Drive"""
191
+ if not self.service or not self.folder_id:
192
+ return False
193
+
194
+ try:
195
+ # Convert content to appropriate string format
196
+ if isinstance(content, (dict, list)):
197
+ if file_name.endswith('.yaml') or file_name.endswith('.yml'):
198
+ # For YAML files, convert to YAML string
199
+ import yaml
200
+ content_str = yaml.dump(content, default_flow_style=False, sort_keys=False)
201
+ else:
202
+ # For JSON files, convert to JSON string
203
+ content_str = json.dumps(content, indent=2)
204
+ else:
205
+ content_str = str(content)
206
+
207
+ # Convert string to bytes
208
+ content_bytes = content_str.encode('utf-8')
209
+
210
+ # Create a temporary file-like object
211
+ from io import BytesIO
212
+ media_body = BytesIO(content_bytes)
213
+
214
+ # Determine MIME type based on file extension
215
+ if file_name.endswith('.yaml') or file_name.endswith('.yml'):
216
+ mimetype = 'text/yaml'
217
+ else:
218
+ mimetype = 'application/json'
219
+
220
+ # Handle subfolder paths like 'laraandumang/wedding_config.json'
221
+ target_folder_id = self.folder_id
222
+ actual_file_name = file_name
223
+
224
+ if '/' in file_name:
225
+ folder_name, actual_file_name = file_name.split('/', 1)
226
+
227
+ # First, find the subfolder
228
+ folder_query = f"name='{folder_name}' and '{self.folder_id}' in parents and trashed=false and mimeType='application/vnd.google-apps.folder'"
229
+ folder_results = self.service.files().list(q=folder_query).execute()
230
+ folders = folder_results.get('files', [])
231
+
232
+ if not folders:
233
+ # Create the subfolder if it doesn't exist
234
+ folder_metadata = {
235
+ 'name': folder_name,
236
+ 'mimeType': 'application/vnd.google-apps.folder',
237
+ 'parents': [self.folder_id]
238
+ }
239
+ created_folder = self.service.files().create(
240
+ body=folder_metadata,
241
+ fields='id'
242
+ ).execute()
243
+ target_folder_id = created_folder.get('id')
244
+ else:
245
+ target_folder_id = folders[0]['id']
246
+
247
+ # Check if file already exists in the target folder
248
+ query = f"name='{actual_file_name}' and '{target_folder_id}' in parents and trashed=false"
249
+ results = self.service.files().list(q=query).execute()
250
+ existing_files = results.get('files', [])
251
+
252
+ if existing_files:
253
+ # Update existing file
254
+ file_id = existing_files[0]['id']
255
+ from googleapiclient.http import MediaIoBaseUpload
256
+ media = MediaIoBaseUpload(media_body, mimetype=mimetype, resumable=True)
257
+ self.service.files().update(
258
+ fileId=file_id,
259
+ media_body=media
260
+ ).execute()
261
+ else:
262
+ # Create new file
263
+ file_metadata = {
264
+ 'name': actual_file_name,
265
+ 'parents': [target_folder_id]
266
+ }
267
+ from googleapiclient.http import MediaIoBaseUpload
268
+ media = MediaIoBaseUpload(media_body, mimetype=mimetype, resumable=True)
269
+ self.service.files().create(
270
+ body=file_metadata,
271
+ media_body=media
272
+ ).execute()
273
+
274
+ return True
275
+
276
+ except HttpError as e:
277
+ st.error(f"Error uploading file '{file_name}': {str(e)}")
278
+ return False
279
+ except Exception as e:
280
+ st.error(f"Unexpected error uploading file '{file_name}': {str(e)}")
281
+ return False
282
+
283
+ def sync_from_drive(self, file_names: List[str]) -> Dict[str, Any]:
284
+ """Download multiple files from Google Drive"""
285
+ synced_files = {}
286
+
287
+ for file_name in file_names:
288
+ content = self.download_file(file_name)
289
+ if content is not None:
290
+ synced_files[file_name] = content
291
+ # Save to local temp directory
292
+ local_path = os.path.join(self.temp_dir, file_name)
293
+ with open(local_path, 'w') as f:
294
+ if isinstance(content, (dict, list)):
295
+ json.dump(content, f, indent=2)
296
+ else:
297
+ f.write(str(content))
298
+
299
+ return synced_files
300
+
301
+ def sync_to_drive(self, file_names: List[str], local_data: Dict[str, Any]) -> bool:
302
+ """Upload multiple files to Google Drive"""
303
+ success = True
304
+
305
+ for file_name in file_names:
306
+ if file_name in local_data:
307
+ if not self.upload_file(file_name, local_data[file_name]):
308
+ success = False
309
+
310
+ return success
311
+
312
+ def get_file_info(self, file_name: str) -> Optional[Dict[str, Any]]:
313
+ """Get metadata for a specific file"""
314
+ if not self.service or not self.folder_id:
315
+ return None
316
+
317
+ try:
318
+ query = f"name='{file_name}' and '{self.folder_id}' in parents and trashed=false"
319
+ results = self.service.files().list(q=query).execute()
320
+ files = results.get('files', [])
321
+
322
+ if files:
323
+ return files[0]
324
+ return None
325
+
326
+ except HttpError as e:
327
+ st.error(f"Error getting file info for '{file_name}': {str(e)}")
328
+ return None
329
+
330
+ def is_online(self) -> bool:
331
+ """Check if Google Drive service is available"""
332
+ return self.service is not None and self.folder_id is not None
guests.py ADDED
@@ -0,0 +1,1602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import json
3
+ import pandas as pd
4
+ import io
5
+ import hashlib
6
+ import plotly.graph_objects as go
7
+ from datetime import datetime
8
+ from config_manager import ConfigManager
9
+
10
+ class GuestManager:
11
+ def __init__(self):
12
+ self.config_manager = ConfigManager()
13
+ self.guest_list_data = None
14
+ self.rsvp_data = None
15
+
16
+ def render(self, config):
17
+ st.markdown("## 👥 Guest Management")
18
+
19
+ # Load current data
20
+ self.load_data()
21
+
22
+ # Import section
23
+ st.markdown("### Import Data")
24
+ import_type = st.radio("Import Type", ["Guest List (CSV)", "RSVP Responses (CSV)"], horizontal=True)
25
+
26
+ if import_type == "Guest List (CSV)":
27
+ uploaded_file = st.file_uploader("Upload Guest List (CSV)", type=['csv'], help="Upload a CSV file with guest data")
28
+ else:
29
+ uploaded_file = st.file_uploader("Upload RSVP Responses (CSV)", type=['csv'], help="Upload a CSV file with RSVP responses")
30
+
31
+ if uploaded_file is not None:
32
+ try:
33
+ df = pd.read_csv(uploaded_file)
34
+
35
+ if import_type == "Guest List (CSV)":
36
+ self.process_guest_list_csv(df)
37
+ st.success("Guest list processed successfully!")
38
+ st.rerun()
39
+ else:
40
+ self.process_rsvp_csv(df, config)
41
+ st.success("RSVP data processed successfully!")
42
+ st.rerun()
43
+
44
+ except Exception as e:
45
+ st.error(f"Error processing CSV: {e}")
46
+
47
+ # Display current data
48
+ if self.guest_list_data is not None:
49
+ self.render_guest_table(config)
50
+ else:
51
+ st.info("Please upload a guest list CSV file to get started.")
52
+
53
+ def get_confirmed_attendees(self, guests):
54
+ """Get list of guests who have RSVPed 'Yes' to at least one event"""
55
+ confirmed = []
56
+ for guest in guests:
57
+ rsvp_by_event = guest.get('rsvp_by_event', {})
58
+ if any(status == 'Yes' for status in rsvp_by_event.values()):
59
+ confirmed.append(guest)
60
+ return confirmed
61
+
62
+ def render_confirmed_attendees_view(self, confirmed_guests, config):
63
+ """Render detailed view for confirmed attendees with stats"""
64
+
65
+ # Create tabs for different views
66
+ tab1, tab2, tab3, tab4 = st.tabs(["Guest List", "Attendance by Event", "Meal Choices", "Summary Stats"])
67
+
68
+ with tab1:
69
+ self.render_guests_table(confirmed_guests, config)
70
+
71
+ with tab2:
72
+ st.markdown("### Attendance by Event for Confirmed Guests")
73
+ self.render_attendance_chart(confirmed_guests, config)
74
+
75
+ with tab3:
76
+ st.markdown("### Meal Choices for Confirmed Guests")
77
+ self.render_meal_choices_chart(confirmed_guests, config)
78
+
79
+ with tab4:
80
+ st.markdown("### Summary Statistics")
81
+ self.render_confirmed_attendees_stats(confirmed_guests, config)
82
+
83
+ def render_attendance_chart(self, confirmed_guests, config):
84
+ """Render attendance chart for confirmed guests with guest list functionality"""
85
+ wedding_events = config.get('wedding_events', [])
86
+
87
+ # Count attendance for each event
88
+ event_attendance = {}
89
+ for event in wedding_events:
90
+ event_name = event.get('name', '')
91
+ if event_name:
92
+ event_attendance[event_name] = {'Yes': 0, 'No': 0, 'Pending': 0}
93
+
94
+ for guest in confirmed_guests:
95
+ rsvp_by_event = guest.get('rsvp_by_event', {})
96
+ for event_name in event_attendance.keys():
97
+ rsvp_status = rsvp_by_event.get(event_name, 'Pending')
98
+ if rsvp_status in event_attendance[event_name]:
99
+ event_attendance[event_name][rsvp_status] += 1
100
+ else:
101
+ event_attendance[event_name]['Pending'] += 1
102
+
103
+ # Create bar chart
104
+ event_names = list(event_attendance.keys())
105
+ yes_counts = [event_attendance[event]['Yes'] for event in event_names]
106
+ no_counts = [event_attendance[event]['No'] for event in event_names]
107
+ pending_counts = [event_attendance[event]['Pending'] for event in event_names]
108
+
109
+ fig = go.Figure(data=[
110
+ go.Bar(name='Yes', x=event_names, y=yes_counts, marker_color='#4a7c59'),
111
+ go.Bar(name='No', x=event_names, y=no_counts, marker_color='#d32f2f'),
112
+ go.Bar(name='Pending', x=event_names, y=pending_counts, marker_color='#ffa726')
113
+ ])
114
+
115
+ fig.update_layout(
116
+ title="Event Attendance for Confirmed Guests",
117
+ barmode='stack',
118
+ xaxis_tickangle=-45,
119
+ height=400
120
+ )
121
+ st.plotly_chart(fig, use_container_width=True)
122
+
123
+ # Add guest list functionality below the chart
124
+ st.markdown("---")
125
+ st.markdown("### 📋 View Guest Lists by Event")
126
+
127
+ if event_names:
128
+ # Create columns for event selection and guest list display
129
+ col1, col2 = st.columns([1, 2])
130
+
131
+ with col1:
132
+ selected_event = st.selectbox(
133
+ "Select Event to View Guest List:",
134
+ event_names,
135
+ help="Choose an event to see the list of guests who confirmed attendance"
136
+ )
137
+
138
+ # Show attendance summary for selected event
139
+ if selected_event in event_attendance:
140
+ st.markdown("#### Attendance Summary")
141
+ st.metric("Confirmed", event_attendance[selected_event]['Yes'])
142
+ st.metric("Declined", event_attendance[selected_event]['No'])
143
+ st.metric("Pending", event_attendance[selected_event]['Pending'])
144
+
145
+ with col2:
146
+ if selected_event:
147
+ self.render_event_guest_list(confirmed_guests, selected_event, config)
148
+ else:
149
+ st.info("No events configured yet.")
150
+
151
+ def render_event_guest_list(self, confirmed_guests, selected_event, config):
152
+ """Render the list of guests who confirmed for a specific event"""
153
+ st.markdown(f"#### Guest List for {selected_event}")
154
+
155
+ # Get guests who confirmed for this event
156
+ confirmed_for_event = []
157
+ declined_for_event = []
158
+ pending_for_event = []
159
+
160
+ for guest in confirmed_guests:
161
+ rsvp_by_event = guest.get('rsvp_by_event', {})
162
+ rsvp_status = rsvp_by_event.get(selected_event, 'Pending')
163
+
164
+ if rsvp_status == 'Yes':
165
+ confirmed_for_event.append(guest)
166
+ elif rsvp_status == 'No':
167
+ declined_for_event.append(guest)
168
+ else:
169
+ pending_for_event.append(guest)
170
+
171
+ # Create tabs for different RSVP statuses
172
+ tab1, tab2, tab3 = st.tabs([
173
+ f"✅ Confirmed ({len(confirmed_for_event)})",
174
+ f"❌ Declined ({len(declined_for_event)})",
175
+ f"⏳ Pending ({len(pending_for_event)})"
176
+ ])
177
+
178
+ with tab1:
179
+ if confirmed_for_event:
180
+ self.render_guest_list_table(confirmed_for_event, selected_event, config, "confirmed")
181
+ else:
182
+ st.info("No guests have confirmed for this event yet.")
183
+
184
+ with tab2:
185
+ if declined_for_event:
186
+ self.render_guest_list_table(declined_for_event, selected_event, config, "declined")
187
+ else:
188
+ st.info("No guests have declined this event.")
189
+
190
+ with tab3:
191
+ if pending_for_event:
192
+ self.render_guest_list_table(pending_for_event, selected_event, config, "pending")
193
+ else:
194
+ st.info("All guests have responded to this event.")
195
+
196
+ def render_guest_list_table(self, guests, event_name, config, status="confirmed"):
197
+ """Render a table of guests for a specific event"""
198
+ if not guests:
199
+ return
200
+
201
+ # Create data for the table
202
+ table_data = []
203
+ for guest in guests:
204
+ first_name = guest.get('first_name', '')
205
+ last_name = guest.get('last_name', '')
206
+
207
+ # Handle guests with "nan" names
208
+ if first_name in ['nan', ''] and last_name in ['nan', '']:
209
+ first_name = '[Unassigned Plus One]'
210
+ last_name = ''
211
+
212
+ # Get meal selection for this event
213
+ meal_selections = guest.get('meal_selections', {})
214
+ meal_choice = meal_selections.get(event_name, 'Not Selected')
215
+
216
+ # Check if this event requires meal choice
217
+ wedding_events = config.get('wedding_events', [])
218
+ event_requires_meal = False
219
+ for event in wedding_events:
220
+ if event.get('name') == event_name and event.get('requires_meal_choice', False):
221
+ event_requires_meal = True
222
+ break
223
+
224
+ row = {
225
+ 'Name': f"{first_name} {last_name}".strip(),
226
+ 'Group': guest.get('group', ''),
227
+ 'Party': guest.get('party', ''),
228
+ 'Phone': guest.get('phone', ''),
229
+ 'Plus One Name': guest.get('plus_one_name', '')
230
+ }
231
+
232
+ # Add meal choice column only if event requires meal choice
233
+ if event_requires_meal:
234
+ row['Meal Choice'] = meal_choice
235
+
236
+ table_data.append(row)
237
+
238
+ if table_data:
239
+ df = pd.DataFrame(table_data)
240
+
241
+ # Display the table
242
+ st.dataframe(
243
+ df,
244
+ use_container_width=True,
245
+ hide_index=True,
246
+ height=min(400, len(table_data) * 35 + 50) # Dynamic height based on number of guests
247
+ )
248
+
249
+ # Add export functionality
250
+ csv_data = df.to_csv(index=False)
251
+ st.download_button(
252
+ label=f"📥 Download {event_name} Guest List (CSV)",
253
+ data=csv_data,
254
+ file_name=f"{event_name.replace(' ', '_')}_{status}_guest_list.csv",
255
+ mime="text/csv",
256
+ key=f"download_{event_name.replace(' ', '_')}_{status}_{len(guests)}"
257
+ )
258
+
259
+ def render_meal_choices_chart(self, confirmed_guests, config):
260
+ """Render meal choices chart for confirmed guests"""
261
+ wedding_events = config.get('wedding_events', [])
262
+
263
+ # Get events that require meal choices
264
+ meal_events = [event for event in wedding_events if event.get('requires_meal_choice', False)]
265
+
266
+ if not meal_events:
267
+ st.info("No events require meal choices.")
268
+ return
269
+
270
+ for event in meal_events:
271
+ event_name = event.get('name', '')
272
+ meal_options = event.get('meal_options', [])
273
+
274
+ if not meal_options:
275
+ continue
276
+
277
+ st.markdown(f"#### {event_name}")
278
+
279
+ # Count meal choices for this event
280
+ meal_counts = {option: 0 for option in meal_options}
281
+ meal_counts['Not Selected'] = 0
282
+
283
+ for guest in confirmed_guests:
284
+ meal_selections = guest.get('meal_selections', {})
285
+ selected_meal = meal_selections.get(event_name, 'Not Selected')
286
+ if selected_meal in meal_counts:
287
+ meal_counts[selected_meal] += 1
288
+ else:
289
+ meal_counts['Not Selected'] += 1
290
+
291
+ # Create pie chart
292
+ fig = go.Figure(data=[go.Pie(
293
+ labels=list(meal_counts.keys()),
294
+ values=list(meal_counts.values()),
295
+ marker_colors=['#4a7c59', '#ff9800', '#2196f3', '#9c27b0', '#f44336', '#ffa726']
296
+ )])
297
+
298
+ fig.update_layout(title=f"Meal Choices for {event_name}")
299
+ st.plotly_chart(fig, use_container_width=True)
300
+
301
+ def render_confirmed_attendees_stats(self, confirmed_guests, config):
302
+ """Render summary statistics for confirmed attendees"""
303
+ wedding_events = config.get('wedding_events', [])
304
+
305
+ # Basic stats
306
+ col1, col2, col3, col4 = st.columns(4)
307
+
308
+ with col1:
309
+ st.metric("Total Confirmed", len(confirmed_guests))
310
+
311
+ with col2:
312
+ plus_ones = sum(1 for guest in confirmed_guests if guest.get('plus_one', False))
313
+ st.metric("With Plus Ones", plus_ones)
314
+
315
+ with col3:
316
+ total_attending = len(confirmed_guests) + plus_ones
317
+ st.metric("Total Attending", total_attending)
318
+
319
+ with col4:
320
+ # Calculate average attendance across all events
321
+ total_yes = 0
322
+ total_possible = len(confirmed_guests) * len(wedding_events)
323
+ for guest in confirmed_guests:
324
+ rsvp_by_event = guest.get('rsvp_by_event', {})
325
+ total_yes += sum(1 for status in rsvp_by_event.values() if status == 'Yes')
326
+
327
+ avg_attendance = (total_yes / total_possible * 100) if total_possible > 0 else 0
328
+ st.metric("Avg Event Attendance", f"{avg_attendance:.1f}%")
329
+
330
+ # Event-specific stats
331
+ st.markdown("#### Event Attendance Summary")
332
+ event_stats = []
333
+ for event in wedding_events:
334
+ event_name = event.get('name', '')
335
+ yes_count = 0
336
+ for guest in confirmed_guests:
337
+ rsvp_by_event = guest.get('rsvp_by_event', {})
338
+ if rsvp_by_event.get(event_name) == 'Yes':
339
+ yes_count += 1
340
+
341
+ attendance_rate = (yes_count / len(confirmed_guests) * 100) if confirmed_guests else 0
342
+ event_stats.append({
343
+ 'Event': event_name,
344
+ 'Attending': yes_count,
345
+ 'Attendance Rate': f"{attendance_rate:.1f}%"
346
+ })
347
+
348
+ if event_stats:
349
+ stats_df = pd.DataFrame(event_stats)
350
+ st.dataframe(stats_df, use_container_width=True, hide_index=True)
351
+
352
+ def convert_csv_to_guests(self, df):
353
+ """Convert CSV data to guest format"""
354
+ guest_data = []
355
+
356
+ for _, row in df.iterrows():
357
+ first_name = str(row.get('First Name (Empty Rows are +1s)', '')).strip()
358
+ last_name = str(row.get('Last Name', '')).strip()
359
+ group = str(row.get('Group', '')).strip()
360
+ group_size = str(row.get('Group Size', '')).strip()
361
+ party = str(row.get('Party', '')).strip()
362
+ street_address = str(row.get(' Street address', '')).strip()
363
+ apt_suite = str(row.get(' Apt/Suite', '')).strip()
364
+ city = str(row.get(' City', '')).strip()
365
+ state = str(row.get(' State', '')).strip()
366
+ zip_code = str(row.get(' ZIP', '')).strip()
367
+ country = str(row.get('Country', '')).strip()
368
+
369
+ # Skip completely empty rows
370
+ if not first_name and not last_name and not group:
371
+ continue
372
+
373
+ # Build full address
374
+ address_parts = []
375
+ if street_address and street_address != 'nan':
376
+ address_parts.append(street_address)
377
+ if apt_suite and apt_suite != 'nan':
378
+ address_parts.append(f"Apt {apt_suite}")
379
+ if city and city != 'nan':
380
+ address_parts.append(city)
381
+ if state and state != 'nan':
382
+ address_parts.append(state)
383
+ if zip_code and zip_code != 'nan':
384
+ address_parts.append(zip_code)
385
+ if country and country != 'nan':
386
+ address_parts.append(country)
387
+
388
+ full_address = ', '.join(address_parts) if address_parts else ''
389
+
390
+ # Determine if this is a plus one (empty first name but has party/group)
391
+ is_plus_one = not first_name and (group or party)
392
+
393
+ if is_plus_one:
394
+ # This is a plus one entry - create a guest entry with "nan" names
395
+ # The plus_one_name will be filled in from RSVP data
396
+ guest = {
397
+ 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"),
398
+ 'first_name': 'nan',
399
+ 'last_name': 'nan',
400
+ 'party': party,
401
+ 'group': group,
402
+ 'group_size': group_size,
403
+ 'phone': '',
404
+ 'address': full_address, # Use full address as address info
405
+ 'lodging': '', # Leave lodging empty for manual entry
406
+ 'allergies': '',
407
+ 'plus_one': False, # This will be updated from RSVP data
408
+ 'plus_one_name': '', # This will be filled from RSVP data
409
+ 'plus_one_phone': '',
410
+ 'plus_one_allergies': '',
411
+ 'rsvp_by_event': {},
412
+ 'meal_selections': {},
413
+ 'created_date': datetime.now().isoformat()
414
+ }
415
+ else:
416
+ # Create guest object for main guests
417
+ guest = {
418
+ 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"),
419
+ 'first_name': first_name,
420
+ 'last_name': last_name,
421
+ 'party': party,
422
+ 'group': group,
423
+ 'group_size': group_size,
424
+ 'phone': '',
425
+ 'address': full_address, # Use full address as address info
426
+ 'lodging': '', # Leave lodging empty for manual entry
427
+ 'allergies': '',
428
+ 'plus_one': False, # Will be updated from RSVP data
429
+ 'plus_one_name': '',
430
+ 'plus_one_phone': '',
431
+ 'plus_one_allergies': '',
432
+ 'rsvp_by_event': {},
433
+ 'meal_selections': {},
434
+ 'created_date': datetime.now().isoformat()
435
+ }
436
+
437
+ guest_data.append(guest)
438
+
439
+ return guest_data
440
+
441
+ def update_guests_with_rsvp(self, df, config):
442
+ """Update existing guests with RSVP responses from CSV"""
443
+ import json
444
+
445
+ existing_guests = self.config_manager.load_json_data('guests.json')
446
+ updated_count = 0
447
+
448
+ # Track new meal options found in RSVP data
449
+ new_meal_options_by_event = {}
450
+
451
+ # Track which plus one names have been assigned to avoid duplicates across all parties
452
+ global_assigned_plus_ones = set()
453
+
454
+ for _, row in df.iterrows():
455
+ party_code = str(row.get('party_code', '')).strip()
456
+ name = str(row.get('name', '')).strip()
457
+ overall_rsvp = str(row.get('overall_rsvp', '')).strip()
458
+ bringing_plus_one = str(row.get('bringing_plus_one', '')).strip().lower() == 'yes'
459
+ party_attendees = str(row.get('party_attendees', '')).strip()
460
+ event_responses = str(row.get('event_responses', '')).strip()
461
+ dietary_restrictions = str(row.get('dietary_restrictions', '')).strip()
462
+ phone_number = str(row.get('phone_number', '')).strip()
463
+
464
+ # Skip empty rows
465
+ if not party_code and not name:
466
+ continue
467
+
468
+ # Parse party attendees to get all names in the party
469
+ party_attendee_names = []
470
+ if party_attendees and party_attendees != 'nan':
471
+ party_attendee_names = [name.strip() for name in party_attendees.split(';') if name.strip()]
472
+
473
+ # Parse dietary restrictions
474
+ dietary_dict = {}
475
+ if dietary_restrictions and dietary_restrictions != 'nan' and dietary_restrictions != '{}':
476
+ try:
477
+ dietary_dict = json.loads(dietary_restrictions)
478
+ except:
479
+ pass
480
+
481
+ # Parse event responses
482
+ event_dict = {}
483
+ if event_responses and event_responses != 'nan' and event_responses != '{}':
484
+ try:
485
+ event_dict = json.loads(event_responses)
486
+ except Exception as e:
487
+ st.warning(f"Could not parse event responses for {name}: {e}")
488
+
489
+ # First, ensure all party attendees exist as separate guest entries
490
+ # This handles cases where RSVP data includes people not in the original guest list
491
+ for attendee_name in party_attendee_names:
492
+ # Check if this attendee already exists as a guest
493
+ attendee_exists = False
494
+ for guest in existing_guests:
495
+ guest_name = f"{guest.get('first_name', '')} {guest.get('last_name', '')}".strip()
496
+ if attendee_name.lower() == guest_name.lower():
497
+ attendee_exists = True
498
+ break
499
+
500
+ # If attendee doesn't exist, create a new guest entry
501
+ if not attendee_exists:
502
+ # Parse the attendee name into first and last name
503
+ name_parts = attendee_name.strip().split()
504
+ if len(name_parts) >= 2:
505
+ first_name = name_parts[0]
506
+ last_name = ' '.join(name_parts[1:])
507
+ else:
508
+ first_name = attendee_name
509
+ last_name = ''
510
+
511
+ # Find the group/party info from existing guests with the same party code
512
+ group_name = party_code
513
+ party_name = 'Lara' # Default party
514
+ address = ''
515
+ group_size = '1'
516
+
517
+ for guest in existing_guests:
518
+ guest_group = guest.get('group', '').strip()
519
+ if party_code.lower() == guest_group.lower():
520
+ group_name = guest_group
521
+ party_name = guest.get('party', 'Lara')
522
+ address = guest.get('address', '')
523
+ group_size = guest.get('group_size', '1')
524
+ break
525
+
526
+ # Create new guest entry
527
+ new_guest = {
528
+ 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"),
529
+ 'first_name': first_name,
530
+ 'last_name': last_name,
531
+ 'party': party_name,
532
+ 'group': group_name,
533
+ 'group_size': group_size,
534
+ 'phone': phone_number if phone_number != 'nan' else '',
535
+ 'address': address,
536
+ 'lodging': '',
537
+ 'allergies': dietary_dict.get(attendee_name, ''),
538
+ 'plus_one': False, # This is a main guest, not a plus one
539
+ 'plus_one_name': '',
540
+ 'plus_one_phone': '',
541
+ 'plus_one_allergies': '',
542
+ 'rsvp_by_event': {},
543
+ 'meal_selections': {},
544
+ 'created_date': datetime.now().isoformat()
545
+ }
546
+
547
+ # Apply RSVP data to the new guest
548
+ if event_dict:
549
+ rsvp_by_event = {}
550
+ meal_selections = {}
551
+
552
+ for event_name, event_data in event_dict.items():
553
+ if isinstance(event_data, dict):
554
+ attendees = event_data.get('attendees', [])
555
+ party_rsvp = event_data.get('rsvp', 'Pending')
556
+
557
+ # Determine individual RSVP status
558
+ if attendee_name in attendees:
559
+ rsvp_status = party_rsvp
560
+ else:
561
+ rsvp_status = 'No'
562
+
563
+ rsvp_by_event[event_name] = rsvp_status
564
+
565
+ # Update meal choices if provided and guest is attending
566
+ if rsvp_status == 'Yes':
567
+ meal_choice = event_data.get('meal_choice', {})
568
+ if meal_choice and isinstance(meal_choice, dict) and attendee_name in meal_choice:
569
+ selected_meal = meal_choice[attendee_name]
570
+ meal_selections[event_name] = selected_meal
571
+
572
+ # Track new meal options for this event
573
+ if event_name not in new_meal_options_by_event:
574
+ new_meal_options_by_event[event_name] = set()
575
+ new_meal_options_by_event[event_name].add(selected_meal)
576
+
577
+ new_guest['rsvp_by_event'] = rsvp_by_event
578
+ new_guest['meal_selections'] = meal_selections
579
+
580
+ existing_guests.append(new_guest)
581
+ updated_count += 1
582
+
583
+ # Now update existing guests with RSVP data
584
+ # Find matching guest(s) by name or party
585
+ matching_guests = []
586
+ for guest in existing_guests:
587
+ guest_name = f"{guest.get('first_name', '')} {guest.get('last_name', '')}".strip()
588
+ guest_party = guest.get('party', '').strip()
589
+ guest_group = guest.get('group', '').strip()
590
+
591
+ # Match by exact name, party code, or exact group name (including guests with "nan" names)
592
+ # Use exact group matching to prevent partial matches (e.g., "Perrin" matching "Ben Perrin")
593
+ if (name and name.lower() in guest_name.lower()) or \
594
+ (party_code and (party_code.lower() == guest_party.lower() or party_code.lower() == guest_group.lower())):
595
+ matching_guests.append(guest)
596
+
597
+ # Track which plus one names have been assigned for this specific party
598
+ party_assigned_plus_ones = set()
599
+
600
+ # Update matching guests
601
+ for guest in matching_guests:
602
+ guest_name = f"{guest.get('first_name', '')} {guest.get('last_name', '')}".strip()
603
+ is_nan_guest = guest.get('first_name') == 'nan' and guest.get('last_name') == 'nan'
604
+
605
+ # Update phone if provided
606
+ if phone_number and phone_number != 'nan':
607
+ guest['phone'] = self._format_phone_number(phone_number)
608
+
609
+ # Handle guests with "nan" names (these are plus ones that need names)
610
+ if is_nan_guest and party_attendee_names:
611
+ # Find the plus one name that matches this guest's party/group
612
+ # Look for names that aren't the main guest name
613
+ main_guest_name = name # The main guest from RSVP data
614
+ plus_one_candidates = [name for name in party_attendee_names if name.lower() != main_guest_name.lower()]
615
+
616
+ # Find the first plus one candidate that hasn't been assigned yet for this party
617
+ plus_one_name = None
618
+ for candidate in plus_one_candidates:
619
+ if candidate not in party_assigned_plus_ones:
620
+ plus_one_name = candidate
621
+ party_assigned_plus_ones.add(candidate)
622
+ break
623
+
624
+ if plus_one_name:
625
+ # Parse the plus one name into first and last name
626
+ name_parts = plus_one_name.strip().split()
627
+ if len(name_parts) >= 2:
628
+ guest['first_name'] = name_parts[0]
629
+ guest['last_name'] = ' '.join(name_parts[1:]) # Join remaining parts as last name
630
+ else:
631
+ guest['first_name'] = plus_one_name
632
+ guest['last_name'] = ''
633
+
634
+ guest['plus_one'] = False # This is now a named guest
635
+ guest['plus_one_name'] = '' # Clear the plus one name field
636
+
637
+ # Update allergies for the plus one
638
+ if plus_one_name in dietary_dict:
639
+ guest['allergies'] = dietary_dict[plus_one_name]
640
+
641
+ # Apply party RSVP to this plus one
642
+ if event_dict:
643
+ rsvp_by_event = {}
644
+ meal_selections = {}
645
+
646
+ for event_name, event_data in event_dict.items():
647
+ if isinstance(event_data, dict):
648
+ # Check if this plus one is specifically listed as an attendee
649
+ attendees = event_data.get('attendees', [])
650
+ party_rsvp = event_data.get('rsvp', 'Pending')
651
+
652
+ # Determine individual RSVP status
653
+ if plus_one_name in attendees:
654
+ # Plus one is listed as attending, use party RSVP
655
+ rsvp_status = party_rsvp
656
+ else:
657
+ # Plus one is not listed as attending, mark as "No"
658
+ rsvp_status = 'No'
659
+
660
+ rsvp_by_event[event_name] = rsvp_status
661
+
662
+ # Update meal choices if provided and plus one is attending
663
+ if rsvp_status == 'Yes':
664
+ meal_choice = event_data.get('meal_choice', {})
665
+ if meal_choice and isinstance(meal_choice, dict) and plus_one_name in meal_choice:
666
+ selected_meal = meal_choice[plus_one_name]
667
+ meal_selections[event_name] = selected_meal
668
+
669
+ # Track new meal options for this event
670
+ if event_name not in new_meal_options_by_event:
671
+ new_meal_options_by_event[event_name] = set()
672
+ new_meal_options_by_event[event_name].add(selected_meal)
673
+
674
+ guest['rsvp_by_event'] = rsvp_by_event
675
+ guest['meal_selections'] = meal_selections
676
+
677
+ # Handle named guests (main guests) - but don't assign plus ones if they're separate guests
678
+ elif not is_nan_guest:
679
+ # Check if this guest should have a plus one
680
+ # Only assign plus one if there are attendees not already represented as separate guests
681
+ other_attendees = [name for name in party_attendee_names if name.lower() != guest_name.lower()]
682
+
683
+ # Check if any of these other attendees are already separate guests
684
+ separate_guests = []
685
+ for other_attendee in other_attendees:
686
+ for existing_guest in existing_guests:
687
+ existing_guest_name = f"{existing_guest.get('first_name', '')} {existing_guest.get('last_name', '')}".strip()
688
+ if other_attendee.lower() == existing_guest_name.lower():
689
+ separate_guests.append(other_attendee)
690
+
691
+ # Only assign plus one if there are attendees that aren't separate guests
692
+ plus_one_candidates = [name for name in other_attendees if name not in separate_guests]
693
+
694
+ if plus_one_candidates:
695
+ guest['plus_one'] = True
696
+ guest['plus_one_name'] = plus_one_candidates[0] # Take the first plus one
697
+
698
+ # Update plus one allergies
699
+ if plus_one_candidates[0] in dietary_dict:
700
+ guest['plus_one_allergies'] = dietary_dict[plus_one_candidates[0]]
701
+ else:
702
+ guest['plus_one'] = False
703
+ guest['plus_one_name'] = ''
704
+
705
+ # Update allergies for the main guest
706
+ if guest_name in dietary_dict:
707
+ guest['allergies'] = dietary_dict[guest_name]
708
+
709
+ # Apply party RSVP to main guest
710
+ if event_dict:
711
+ rsvp_by_event = guest.get('rsvp_by_event', {})
712
+ meal_selections = guest.get('meal_selections', {})
713
+
714
+ for event_name, event_data in event_dict.items():
715
+ if isinstance(event_data, dict):
716
+ # Check if this guest is specifically listed as an attendee
717
+ attendees = event_data.get('attendees', [])
718
+ party_rsvp = event_data.get('rsvp', 'Pending')
719
+
720
+ # Determine individual RSVP status
721
+ if guest_name in attendees:
722
+ # Guest is listed as attending, use party RSVP
723
+ rsvp_status = party_rsvp
724
+ else:
725
+ # Guest is not listed as attending, mark as "No"
726
+ rsvp_status = 'No'
727
+
728
+ rsvp_by_event[event_name] = rsvp_status
729
+
730
+ # Update meal choices if provided and guest is attending
731
+ if rsvp_status == 'Yes':
732
+ meal_choice = event_data.get('meal_choice', {})
733
+ if meal_choice and isinstance(meal_choice, dict) and guest_name in meal_choice:
734
+ selected_meal = meal_choice[guest_name]
735
+ meal_selections[event_name] = selected_meal
736
+
737
+ # Track new meal options for this event
738
+ if event_name not in new_meal_options_by_event:
739
+ new_meal_options_by_event[event_name] = set()
740
+ new_meal_options_by_event[event_name].add(selected_meal)
741
+
742
+ guest['rsvp_by_event'] = rsvp_by_event
743
+ guest['meal_selections'] = meal_selections
744
+
745
+ updated_count += 1
746
+
747
+ # Update event configuration with new meal options
748
+ config_updated = False
749
+ if new_meal_options_by_event:
750
+ wedding_events = config.get('wedding_events', [])
751
+ for event in wedding_events:
752
+ event_name = event.get('name', '')
753
+ if event_name in new_meal_options_by_event:
754
+ # Get existing meal options for this event
755
+ existing_meal_options = set(event.get('meal_options', []))
756
+ # Get new meal options from RSVP data
757
+ new_meal_options = new_meal_options_by_event[event_name]
758
+
759
+ # Merge existing and new meal options
760
+ all_meal_options = existing_meal_options.union(new_meal_options)
761
+
762
+ # Update the event configuration
763
+ event['meal_options'] = sorted(list(all_meal_options))
764
+ config_updated = True
765
+
766
+ # Save updated configuration if changes were made
767
+ if config_updated:
768
+ self.config_manager.save_config(config)
769
+ st.info(f"Updated event meal options with new choices from RSVP data")
770
+
771
+ # Clean up any remaining "nan" guests that weren't updated
772
+ # These are likely plus ones that don't have corresponding RSVP data
773
+ for guest in existing_guests:
774
+ if guest.get('first_name') == 'nan' and guest.get('last_name') == 'nan':
775
+ # If this nan guest still has no name, it means no RSVP data was provided
776
+ # We can either keep it as a placeholder or remove it
777
+ # For now, we'll keep it but mark it as inactive
778
+ guest['plus_one'] = False
779
+ guest['plus_one_name'] = ''
780
+
781
+ # Clean up duplicate guests
782
+ existing_guests = self.remove_duplicate_guests(existing_guests)
783
+
784
+ # Save updated guests
785
+ if updated_count > 0:
786
+ self.config_manager.save_json_data('guests.json', existing_guests)
787
+
788
+ return updated_count
789
+
790
+ def remove_duplicate_guests(self, guests):
791
+ """Remove duplicate guests based on name and group"""
792
+ seen_guests = set()
793
+ unique_guests = []
794
+
795
+ for guest in guests:
796
+ first_name = guest.get('first_name', '').strip()
797
+ last_name = guest.get('last_name', '').strip()
798
+ group = guest.get('group', '').strip()
799
+
800
+ # Create a unique identifier for this guest
801
+ guest_key = f"{first_name.lower()}_{last_name.lower()}_{group.lower()}"
802
+
803
+ if guest_key not in seen_guests:
804
+ seen_guests.add(guest_key)
805
+ unique_guests.append(guest)
806
+ else:
807
+ # This is a duplicate - we can optionally log it or merge data
808
+ st.warning(f"Removed duplicate guest: {first_name} {last_name} from {group}")
809
+
810
+ return unique_guests
811
+
812
+ def render_guest_form(self, config):
813
+ """Render form to add new guest"""
814
+ with st.form("guest_form"):
815
+ st.markdown("#### Basic Information")
816
+ col1, col2 = st.columns(2)
817
+
818
+ with col1:
819
+ first_name = st.text_input("First Name *", placeholder="Enter first name")
820
+ last_name = st.text_input("Last Name *", placeholder="Enter last name")
821
+ group = st.text_input("Group", placeholder="Enter group name")
822
+ party = st.text_input("Party/Family", placeholder="Enter party or family name")
823
+
824
+ with col2:
825
+ phone = st.text_input("Phone Number", placeholder="Enter phone number")
826
+ address = st.text_input("Address", placeholder="Home address")
827
+ lodging = st.text_input("Wedding Lodging", placeholder="Hotel, Airbnb, etc. for wedding")
828
+ allergies = st.text_area("Allergies/Dietary Restrictions", placeholder="List any allergies or dietary restrictions")
829
+
830
+ st.markdown("#### Plus One Information")
831
+ plus_one = st.checkbox("Bringing Plus One")
832
+
833
+ if plus_one:
834
+ col1, col2 = st.columns(2)
835
+ with col1:
836
+ plus_one_name = st.text_input("Plus One Name", placeholder="Enter plus one's name")
837
+ plus_one_phone = st.text_input("Plus One Phone", placeholder="Enter plus one's phone")
838
+ with col2:
839
+ plus_one_allergies = st.text_area("Plus One Allergies", placeholder="Plus one's allergies/dietary restrictions")
840
+ else:
841
+ plus_one_name = ""
842
+ plus_one_phone = ""
843
+ plus_one_allergies = ""
844
+
845
+ # RSVP and meal selections for each event
846
+ wedding_events = config.get('wedding_events', [])
847
+ if wedding_events:
848
+ st.markdown("#### RSVP & Meal Selections")
849
+ rsvp_by_event = {}
850
+ meal_selections = {}
851
+
852
+ for event in wedding_events:
853
+ event_name = event.get('name', '')
854
+ if event_name:
855
+ col1, col2 = st.columns(2)
856
+
857
+ with col1:
858
+ # RSVP selection
859
+ rsvp_options = ['Pending', 'Yes', 'No']
860
+ rsvp = st.selectbox(f"RSVP for {event_name}", rsvp_options, key=f"add_rsvp_{event_name}")
861
+ rsvp_by_event[event_name] = rsvp
862
+
863
+ with col2:
864
+ # Meal selection (only if event requires meal choice)
865
+ if event.get('requires_meal_choice', False):
866
+ meal_options = event.get('meal_options', [])
867
+ if meal_options:
868
+ meal_options_with_default = ['Not Selected'] + meal_options
869
+ meal = st.selectbox(f"Meal for {event_name}", meal_options_with_default, key=f"add_meal_{event_name}")
870
+ meal_selections[event_name] = meal
871
+
872
+ submitted = st.form_submit_button("Add Guest", type="primary")
873
+
874
+ if submitted:
875
+ if first_name and last_name:
876
+ new_guest = {
877
+ 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"),
878
+ 'first_name': first_name,
879
+ 'last_name': last_name,
880
+ 'group': group,
881
+ 'party': party,
882
+ 'phone': phone,
883
+ 'address': address,
884
+ 'lodging': lodging,
885
+ 'allergies': allergies,
886
+ 'plus_one': plus_one,
887
+ 'plus_one_name': plus_one_name,
888
+ 'plus_one_phone': plus_one_phone,
889
+ 'plus_one_allergies': plus_one_allergies,
890
+ 'rsvp_by_event': rsvp_by_event if wedding_events else {},
891
+ 'meal_selections': meal_selections if wedding_events else {},
892
+ 'created_date': datetime.now().isoformat()
893
+ }
894
+
895
+ # Load existing guests and add new one
896
+ guests = self.config_manager.load_json_data('guests.json')
897
+ guests.append(new_guest)
898
+
899
+ if self.config_manager.save_json_data('guests.json', guests):
900
+ st.success("Guest added successfully!")
901
+ st.rerun()
902
+ else:
903
+ st.error("Error saving guest")
904
+ else:
905
+ st.error("Please enter at least first and last name")
906
+
907
+ def render_guests_table(self, guests, config):
908
+ """Render guests in an editable table format"""
909
+ # Get wedding events for RSVP and meal options
910
+ wedding_events = config.get('wedding_events', [])
911
+
912
+ # Create DataFrame for display
913
+ df_data = []
914
+ for guest in guests:
915
+ # Handle guests with "nan" names (these are plus ones)
916
+ first_name = guest.get('first_name', '')
917
+ last_name = guest.get('last_name', '')
918
+
919
+ # If first/last name is "nan" or empty, this is likely an unassigned plus one
920
+ if first_name in ['nan', ''] and last_name in ['nan', '']:
921
+ # This is an unassigned plus one placeholder
922
+ first_name = '[Unassigned Plus One]'
923
+ last_name = ''
924
+
925
+ row = {
926
+ 'First Name': first_name,
927
+ 'Last Name': last_name,
928
+ 'Group': guest.get('group', ''),
929
+ 'Party': guest.get('party', ''),
930
+ 'Phone': guest.get('phone', ''),
931
+ 'Address': guest.get('address', ''),
932
+ 'Wedding Lodging': guest.get('lodging', ''),
933
+ 'Allergies': guest.get('allergies', ''),
934
+ 'Plus One Allergies': guest.get('plus_one_allergies', '')
935
+ }
936
+
937
+ # Add RSVP columns for each event
938
+ rsvp_by_event = guest.get('rsvp_by_event', {})
939
+ for event in wedding_events:
940
+ event_name = event.get('name', '')
941
+ if event_name:
942
+ row[f'RSVP - {event_name}'] = rsvp_by_event.get(event_name, 'Pending')
943
+
944
+ # Add meal selection columns for events that require meal choice
945
+ meal_selections = guest.get('meal_selections', {})
946
+ for event in wedding_events:
947
+ if event.get('requires_meal_choice', False):
948
+ event_name = event.get('name', '')
949
+ if event_name:
950
+ row[f'Meal - {event_name}'] = meal_selections.get(event_name, 'Not Selected')
951
+
952
+ df_data.append(row)
953
+
954
+ if df_data:
955
+ df = pd.DataFrame(df_data)
956
+
957
+ # Display the table
958
+ st.dataframe(
959
+ df,
960
+ use_container_width=True,
961
+ hide_index=True,
962
+ height=400
963
+ )
964
+
965
+ # Edit/Delete guest section
966
+ st.markdown("### Manage Guest")
967
+ guest_ids = [f"{guest.get('first_name', '')} {guest.get('last_name', '')}" for guest in guests]
968
+ selected_guest_name = st.selectbox("Select Guest to Manage", guest_ids)
969
+
970
+ if selected_guest_name:
971
+ # Find the selected guest
972
+ selected_guest = None
973
+ for guest in guests:
974
+ if f"{guest.get('first_name', '')} {guest.get('last_name', '')}" == selected_guest_name:
975
+ selected_guest = guest
976
+ break
977
+
978
+ if selected_guest:
979
+ # Create tabs for edit and delete
980
+ tab1, tab2 = st.tabs(["Edit Guest", "Delete Guest"])
981
+
982
+ with tab1:
983
+ self.render_guest_edit_form(selected_guest, guests, config)
984
+
985
+ with tab2:
986
+ self.render_guest_delete_form(selected_guest, guests)
987
+
988
+ def render_guest_edit_form(self, guest, all_guests, config):
989
+ """Render form to edit guest information"""
990
+ with st.form(f"edit_guest_{guest.get('id', '')}"):
991
+ col1, col2 = st.columns(2)
992
+
993
+ with col1:
994
+ first_name = st.text_input("First Name", value=guest.get('first_name', ''), key=f"edit_first_{guest.get('id', '')}")
995
+ last_name = st.text_input("Last Name", value=guest.get('last_name', ''), key=f"edit_last_{guest.get('id', '')}")
996
+ group = st.text_input("Group", value=guest.get('group', ''), key=f"edit_group_{guest.get('id', '')}")
997
+ party = st.text_input("Party/Family", value=guest.get('party', ''), key=f"edit_party_{guest.get('id', '')}")
998
+ phone = st.text_input("Phone Number", value=guest.get('phone', ''), key=f"edit_phone_{guest.get('id', '')}")
999
+ address = st.text_input("Address", value=guest.get('address', ''), key=f"edit_address_{guest.get('id', '')}")
1000
+ lodging = st.text_input("Wedding Lodging", value=guest.get('lodging', ''), key=f"edit_lodging_{guest.get('id', '')}")
1001
+
1002
+ with col2:
1003
+ allergies = st.text_area("Allergies/Dietary Restrictions", value=guest.get('allergies', ''), key=f"edit_allergies_{guest.get('id', '')}")
1004
+ plus_one = st.checkbox("Bringing Plus One", value=guest.get('plus_one', False), key=f"edit_plus_one_{guest.get('id', '')}")
1005
+
1006
+ if plus_one:
1007
+ plus_one_name = st.text_input("Plus One Name", value=guest.get('plus_one_name', ''), key=f"edit_plus_one_name_{guest.get('id', '')}")
1008
+ plus_one_phone = st.text_input("Plus One Phone", value=guest.get('plus_one_phone', ''), key=f"edit_plus_one_phone_{guest.get('id', '')}")
1009
+ plus_one_allergies = st.text_area("Plus One Allergies", value=guest.get('plus_one_allergies', ''), key=f"edit_plus_one_allergies_{guest.get('id', '')}")
1010
+ else:
1011
+ plus_one_name = ""
1012
+ plus_one_phone = ""
1013
+ plus_one_allergies = ""
1014
+
1015
+ # RSVP and meal selections for each event
1016
+ st.markdown("#### RSVP & Meal Selections")
1017
+ wedding_events = config.get('wedding_events', [])
1018
+ rsvp_by_event = guest.get('rsvp_by_event', {})
1019
+ meal_selections = guest.get('meal_selections', {})
1020
+
1021
+ for event in wedding_events:
1022
+ event_name = event.get('name', '')
1023
+ if event_name:
1024
+ col1, col2 = st.columns(2)
1025
+
1026
+ with col1:
1027
+ # RSVP selection
1028
+ current_rsvp = rsvp_by_event.get(event_name, 'Pending')
1029
+ rsvp_options = ['Pending', 'Yes', 'No']
1030
+ rsvp_index = rsvp_options.index(current_rsvp) if current_rsvp in rsvp_options else 0
1031
+ new_rsvp = st.selectbox(f"RSVP for {event_name}", rsvp_options, index=rsvp_index, key=f"edit_rsvp_{event_name}_{guest.get('id', '')}")
1032
+ rsvp_by_event[event_name] = new_rsvp
1033
+
1034
+ with col2:
1035
+ # Meal selection (only if event requires meal choice)
1036
+ if event.get('requires_meal_choice', False):
1037
+ meal_options = event.get('meal_options', [])
1038
+ if meal_options:
1039
+ current_meal = meal_selections.get(event_name, 'Not Selected')
1040
+ meal_options_with_default = ['Not Selected'] + meal_options
1041
+ meal_index = meal_options_with_default.index(current_meal) if current_meal in meal_options_with_default else 0
1042
+ new_meal = st.selectbox(f"Meal for {event_name}", meal_options_with_default, index=meal_index, key=f"edit_meal_{event_name}_{guest.get('id', '')}")
1043
+ meal_selections[event_name] = new_meal
1044
+
1045
+ submitted = st.form_submit_button("Save Changes", type="primary")
1046
+
1047
+ if submitted:
1048
+ # Update guest data
1049
+ guest['first_name'] = first_name
1050
+ guest['last_name'] = last_name
1051
+ guest['group'] = group
1052
+ guest['party'] = party
1053
+ guest['phone'] = phone
1054
+ guest['address'] = address
1055
+ guest['lodging'] = lodging
1056
+ guest['allergies'] = allergies
1057
+ guest['plus_one'] = plus_one
1058
+ guest['plus_one_name'] = plus_one_name
1059
+ guest['plus_one_phone'] = plus_one_phone
1060
+ guest['plus_one_allergies'] = plus_one_allergies
1061
+ guest['rsvp_by_event'] = rsvp_by_event
1062
+ guest['meal_selections'] = meal_selections
1063
+
1064
+ # Save updated guests
1065
+ if self.config_manager.save_json_data('guests.json', all_guests):
1066
+ st.success("Guest updated successfully!")
1067
+ st.rerun()
1068
+ else:
1069
+ st.error("Error saving guest changes")
1070
+
1071
+ def render_guest_delete_form(self, guest, all_guests):
1072
+ """Render form to delete guest"""
1073
+ st.markdown("### ⚠️ Delete Guest")
1074
+
1075
+ # Show guest information for confirmation
1076
+ st.markdown(f"**Guest to Delete:** {guest.get('first_name', '')} {guest.get('last_name', '')}")
1077
+ st.markdown(f"**Party:** {guest.get('party', 'N/A')}")
1078
+ st.markdown(f"**Phone:** {guest.get('phone', 'N/A')}")
1079
+
1080
+ if guest.get('plus_one', False):
1081
+ st.markdown(f"**Plus One:** {guest.get('plus_one_name', 'N/A')}")
1082
+
1083
+ st.warning("⚠️ **Warning:** This action cannot be undone. All guest information, RSVP responses, and meal selections will be permanently deleted.")
1084
+
1085
+ # Confirmation checkbox
1086
+ confirm_delete = st.checkbox("I understand this action cannot be undone", key=f"confirm_delete_{guest.get('id', '')}")
1087
+
1088
+ # Delete button
1089
+ if st.button("🗑️ Delete Guest", type="secondary", disabled=not confirm_delete, key=f"delete_guest_{guest.get('id', '')}"):
1090
+ # Remove guest from list
1091
+ all_guests = [g for g in all_guests if g.get('id') != guest.get('id')]
1092
+
1093
+ # Save updated guests
1094
+ if self.config_manager.save_json_data('guests.json', all_guests):
1095
+ st.success(f"Guest {guest.get('first_name', '')} {guest.get('last_name', '')} has been deleted successfully!")
1096
+ st.rerun()
1097
+ else:
1098
+ st.error("Error deleting guest")
1099
+
1100
+ def get_guest_summary(self, guests):
1101
+ """Get summary statistics for guests"""
1102
+ total_guests = len(guests)
1103
+ plus_ones = sum(1 for guest in guests if guest.get('plus_one', False))
1104
+ total_attendees = total_guests + plus_ones
1105
+
1106
+ return {
1107
+ 'total_guests': total_guests,
1108
+ 'plus_ones': plus_ones,
1109
+ 'total_attendees': total_attendees
1110
+ }
1111
+
1112
+ def load_data(self):
1113
+ """Load guest list and RSVP data from storage"""
1114
+ self.guest_list_data = self.config_manager.load_json_data('guest_list_data.json')
1115
+ self.rsvp_data = self.config_manager.load_json_data('rsvp_data.json')
1116
+
1117
+ def save_data(self):
1118
+ """Save guest list and RSVP data to storage"""
1119
+ if self.guest_list_data is not None:
1120
+ self.config_manager.save_json_data('guest_list_data.json', self.guest_list_data)
1121
+ if self.rsvp_data is not None:
1122
+ self.config_manager.save_json_data('rsvp_data.json', self.rsvp_data)
1123
+
1124
+ def process_guest_list_csv(self, df):
1125
+ """Process guest list CSV and create structured data"""
1126
+ groups = {}
1127
+
1128
+ for _, row in df.iterrows():
1129
+ first_name = str(row.get('First Name (Empty Rows are +1s)', '')).strip()
1130
+ last_name = str(row.get('Last Name', '')).strip()
1131
+ group_name = str(row.get('Group', '')).strip()
1132
+ group_size = str(row.get('Group Size', '')).strip()
1133
+ party = str(row.get('Party', '')).strip()
1134
+
1135
+ # Skip empty rows
1136
+ if not group_name:
1137
+ continue
1138
+
1139
+ # Initialize group if not exists
1140
+ if group_name not in groups:
1141
+ groups[group_name] = {
1142
+ 'group_name': group_name,
1143
+ 'group_size': int(group_size) if group_size.isdigit() else 0,
1144
+ 'party': party,
1145
+ 'address': self._build_address(row),
1146
+ 'named_guests': [],
1147
+ 'plus_one_spots': 0
1148
+ }
1149
+
1150
+ # Check if this is a named guest or plus one spot
1151
+ # A named guest has at least a first name (last name can be empty)
1152
+ # A plus one spot has both first and last names empty
1153
+ if first_name and first_name != 'nan':
1154
+ # Named guest (even if last name is empty)
1155
+ full_name = f"{first_name} {last_name}".strip() if last_name and last_name != 'nan' else first_name
1156
+ groups[group_name]['named_guests'].append({
1157
+ 'first_name': first_name,
1158
+ 'last_name': last_name if last_name and last_name != 'nan' else '',
1159
+ 'full_name': full_name
1160
+ })
1161
+ else:
1162
+ # Plus one spot (both first and last names are empty)
1163
+ groups[group_name]['plus_one_spots'] += 1
1164
+
1165
+ self.guest_list_data = groups
1166
+ self.save_data()
1167
+
1168
+ def process_rsvp_csv(self, df, config):
1169
+ """Process RSVP CSV and update guest data"""
1170
+ if self.guest_list_data is None:
1171
+ st.error("Please upload guest list first")
1172
+ return
1173
+
1174
+ rsvp_responses = {}
1175
+
1176
+ for _, row in df.iterrows():
1177
+ group_code = str(row.get('group_code', '')).strip()
1178
+ name = str(row.get('name', '')).strip()
1179
+ overall_rsvp = str(row.get('overall_rsvp', '')).strip()
1180
+ bringing_plus_one = str(row.get('bringing_plus_one', '')).strip().lower() == 'yes'
1181
+ group_attendees = str(row.get('group_attendees', '')).strip()
1182
+ event_responses = str(row.get('event_responses', '')).strip()
1183
+ dietary_restrictions = str(row.get('dietary_restrictions', '')).strip()
1184
+ phone_number = str(row.get('phone_number', '')).strip()
1185
+
1186
+ if not group_code:
1187
+ continue
1188
+
1189
+ # Parse event responses
1190
+ event_dict = {}
1191
+ if event_responses and event_responses != 'nan' and event_responses != '{}':
1192
+ try:
1193
+ event_dict = json.loads(event_responses)
1194
+ except:
1195
+ pass
1196
+
1197
+ # Parse dietary restrictions
1198
+ dietary_dict = {}
1199
+ if dietary_restrictions and dietary_restrictions != 'nan' and dietary_restrictions != '{}':
1200
+ try:
1201
+ dietary_dict = json.loads(dietary_restrictions)
1202
+ except:
1203
+ pass
1204
+
1205
+ # Parse group attendees
1206
+ attendee_names = []
1207
+ if group_attendees and group_attendees != 'nan':
1208
+ attendee_names = [name.strip() for name in group_attendees.split(';') if name.strip()]
1209
+
1210
+ rsvp_responses[group_code] = {
1211
+ 'group_code': group_code,
1212
+ 'name': name,
1213
+ 'overall_rsvp': overall_rsvp,
1214
+ 'bringing_plus_one': bringing_plus_one,
1215
+ 'group_attendees': attendee_names,
1216
+ 'event_responses': event_dict,
1217
+ 'dietary_restrictions': dietary_dict,
1218
+ 'phone_number': self._format_phone_number(phone_number)
1219
+ }
1220
+
1221
+ self.rsvp_data = rsvp_responses
1222
+ self.save_data()
1223
+
1224
+ def _format_phone_number(self, phone):
1225
+ """Format phone number for display"""
1226
+ if not phone or phone.strip() == '' or phone == 'nan':
1227
+ return 'No phone provided'
1228
+
1229
+ # Remove all non-digit characters
1230
+ digits_only = ''.join(filter(str.isdigit, phone))
1231
+
1232
+ # Handle empty result
1233
+ if not digits_only:
1234
+ return 'No phone provided'
1235
+
1236
+ # US phone numbers (10 digits)
1237
+ if len(digits_only) == 10:
1238
+ return f"({digits_only[:3]}) {digits_only[3:6]}-{digits_only[6:]}"
1239
+
1240
+ # US phone numbers with country code (11 digits starting with 1)
1241
+ elif len(digits_only) == 11 and digits_only.startswith('1'):
1242
+ return f"+1 ({digits_only[1:4]}) {digits_only[4:7]}-{digits_only[7:]}"
1243
+
1244
+ # International numbers (more than 11 digits or doesn't start with 1)
1245
+ elif len(digits_only) > 11 or (len(digits_only) == 11 and not digits_only.startswith('1')):
1246
+ # Format as international with + prefix
1247
+ return f"+{digits_only}"
1248
+
1249
+ # Other cases - return as is with some formatting
1250
+ else:
1251
+ return phone.strip()
1252
+
1253
+ def _build_address(self, row):
1254
+ """Build full address from row data"""
1255
+ address_parts = []
1256
+ street = str(row.get(' Street address', '')).strip()
1257
+ apt = str(row.get(' Apt/Suite', '')).strip()
1258
+ city = str(row.get(' City', '')).strip()
1259
+ state = str(row.get(' State', '')).strip()
1260
+ zip_code = str(row.get(' ZIP', '')).strip()
1261
+ country = str(row.get('Country', '')).strip()
1262
+
1263
+ if street and street != 'nan':
1264
+ address_parts.append(street)
1265
+ if apt and apt != 'nan':
1266
+ address_parts.append(f"Apt {apt}")
1267
+ if city and city != 'nan':
1268
+ address_parts.append(city)
1269
+ if state and state != 'nan':
1270
+ address_parts.append(state)
1271
+ if zip_code and zip_code != 'nan':
1272
+ address_parts.append(zip_code)
1273
+ if country and country != 'nan':
1274
+ address_parts.append(country)
1275
+
1276
+ return ', '.join(address_parts)
1277
+
1278
+ def render_guest_table(self, config):
1279
+ """Render the comprehensive guest table"""
1280
+ if not self.guest_list_data:
1281
+ return
1282
+
1283
+ st.markdown("### Guest List Overview")
1284
+
1285
+ # Create comprehensive guest list
1286
+ all_guests = self._create_comprehensive_guest_list(config)
1287
+
1288
+ if not all_guests:
1289
+ st.info("No guests to display")
1290
+ return
1291
+
1292
+ # Add filter options
1293
+ col1, col2 = st.columns([1, 3])
1294
+ with col1:
1295
+ filter_option = st.selectbox(
1296
+ "Filter Guests:",
1297
+ ["All Guests", "Confirmed Guests Only"],
1298
+ help="Filter to show all guests or only those who have confirmed attendance to at least one event"
1299
+ )
1300
+
1301
+ # Filter guests based on selection
1302
+ if filter_option == "Confirmed Guests Only":
1303
+ confirmed_guests = [guest for guest in all_guests if any(status == 'Yes' for status in guest.get('rsvp_by_event', {}).values())]
1304
+ display_guests = confirmed_guests
1305
+ st.info(f"Showing {len(confirmed_guests)} confirmed guests out of {len(all_guests)} total guests")
1306
+ else:
1307
+ display_guests = all_guests
1308
+ st.info(f"Showing all {len(all_guests)} guests")
1309
+
1310
+ # Create DataFrame for display
1311
+ df_data = []
1312
+ for guest in display_guests:
1313
+ row = {
1314
+ 'Name': guest['display_name'],
1315
+ 'Group': guest['group_name'],
1316
+ 'Party': guest['party'],
1317
+ 'Phone': guest.get('phone', ''),
1318
+ 'Address': guest.get('address', '')
1319
+ }
1320
+
1321
+ # Add RSVP columns for each event
1322
+ wedding_events = config.get('wedding_events', [])
1323
+ for event in wedding_events:
1324
+ event_name = event.get('name', '')
1325
+ if event_name:
1326
+ row[f'RSVP - {event_name}'] = guest.get('rsvp_by_event', {}).get(event_name, 'Pending')
1327
+
1328
+ # Add meal selection columns for events that require meal choice
1329
+ for event in wedding_events:
1330
+ if event.get('requires_meal_choice', False):
1331
+ event_name = event.get('name', '')
1332
+ if event_name:
1333
+ row[f'Meal - {event_name}'] = guest.get('meal_selections', {}).get(event_name, 'Not Selected')
1334
+
1335
+ df_data.append(row)
1336
+
1337
+ if df_data:
1338
+ df = pd.DataFrame(df_data)
1339
+
1340
+ # Display the table
1341
+ st.dataframe(
1342
+ df,
1343
+ use_container_width=True,
1344
+ hide_index=True,
1345
+ height=min(600, len(df_data) * 35 + 50)
1346
+ )
1347
+
1348
+ # Summary statistics
1349
+ self._render_summary_stats(display_guests, config)
1350
+
1351
+ def _create_comprehensive_guest_list(self, config):
1352
+ """Create a comprehensive list of all guests with RSVP data"""
1353
+ all_guests = []
1354
+
1355
+ for group_name, group_data in self.guest_list_data.items():
1356
+ # Get RSVP data for this group
1357
+ rsvp_data = self.rsvp_data.get(group_name, {}) if self.rsvp_data else {}
1358
+
1359
+ # Add named guests
1360
+ for named_guest in group_data['named_guests']:
1361
+ guest = {
1362
+ 'display_name': named_guest['full_name'],
1363
+ 'first_name': named_guest['first_name'],
1364
+ 'last_name': named_guest['last_name'],
1365
+ 'group_name': group_name,
1366
+ 'party': group_data['party'],
1367
+ 'address': group_data['address'],
1368
+ 'type': 'Named Guest',
1369
+ 'phone': rsvp_data.get('phone_number', ''),
1370
+ 'rsvp_by_event': {},
1371
+ 'meal_selections': {}
1372
+ }
1373
+
1374
+ # Apply RSVP data
1375
+ self._apply_rsvp_to_guest(guest, rsvp_data, config)
1376
+ all_guests.append(guest)
1377
+
1378
+ # Add plus one spots
1379
+ plus_one_spots = group_data['plus_one_spots']
1380
+ if plus_one_spots > 0:
1381
+ # Get plus one names from RSVP data
1382
+ group_attendees = rsvp_data.get('group_attendees', [])
1383
+ named_guest_names = [g['full_name'] for g in group_data['named_guests']]
1384
+
1385
+ # Find plus one names (attendees not in named guests)
1386
+ plus_one_names = [name for name in group_attendees if name not in named_guest_names]
1387
+
1388
+ # Create plus one entries
1389
+ for i in range(plus_one_spots):
1390
+ if i < len(plus_one_names):
1391
+ # Named plus one
1392
+ plus_one_name = plus_one_names[i]
1393
+ guest = {
1394
+ 'display_name': plus_one_name,
1395
+ 'first_name': plus_one_name.split()[0] if plus_one_name.split() else plus_one_name,
1396
+ 'last_name': ' '.join(plus_one_name.split()[1:]) if len(plus_one_name.split()) > 1 else '',
1397
+ 'group_name': group_name,
1398
+ 'party': group_data['party'],
1399
+ 'address': group_data['address'],
1400
+ 'type': 'Plus One (Named)',
1401
+ 'phone': '',
1402
+ 'rsvp_by_event': {},
1403
+ 'meal_selections': {}
1404
+ }
1405
+ else:
1406
+ # Unnamed plus one
1407
+ guest = {
1408
+ 'display_name': f'Unnamed Plus One {i+1}',
1409
+ 'first_name': '',
1410
+ 'last_name': '',
1411
+ 'group_name': group_name,
1412
+ 'party': group_data['party'],
1413
+ 'address': group_data['address'],
1414
+ 'type': 'Plus One (Unnamed)',
1415
+ 'phone': '',
1416
+ 'rsvp_by_event': {},
1417
+ 'meal_selections': {}
1418
+ }
1419
+
1420
+ # Apply RSVP data
1421
+ self._apply_rsvp_to_guest(guest, rsvp_data, config)
1422
+ all_guests.append(guest)
1423
+
1424
+ return all_guests
1425
+
1426
+ def _apply_rsvp_to_guest(self, guest, rsvp_data, config):
1427
+ """Apply RSVP data to a guest"""
1428
+ wedding_events = config.get('wedding_events', [])
1429
+
1430
+ # Initialize all events with "Pending" status
1431
+ for event in wedding_events:
1432
+ event_name = event.get('name', '')
1433
+ if event_name:
1434
+ guest['rsvp_by_event'][event_name] = 'Pending'
1435
+
1436
+ if not rsvp_data:
1437
+ return
1438
+
1439
+ event_responses = rsvp_data.get('event_responses', {})
1440
+ group_attendees = rsvp_data.get('group_attendees', [])
1441
+ dietary_restrictions = rsvp_data.get('dietary_restrictions', {})
1442
+
1443
+ guest_name = guest['display_name']
1444
+
1445
+ # Apply dietary restrictions
1446
+ if guest_name in dietary_restrictions:
1447
+ guest['allergies'] = dietary_restrictions[guest_name]
1448
+
1449
+ # Apply event responses
1450
+ for event in wedding_events:
1451
+ event_name = event.get('name', '')
1452
+ if event_name in event_responses:
1453
+ event_data = event_responses[event_name]
1454
+ attendees = event_data.get('attendees', [])
1455
+ party_rsvp = event_data.get('rsvp', 'Pending')
1456
+
1457
+ # Determine individual RSVP status
1458
+ if guest_name in attendees:
1459
+ guest['rsvp_by_event'][event_name] = party_rsvp
1460
+
1461
+ # Apply meal choice if attending and event requires meal choice
1462
+ if party_rsvp == 'Yes' and event.get('requires_meal_choice', False):
1463
+ meal_choice = event_data.get('meal_choice', {})
1464
+ if guest_name in meal_choice:
1465
+ guest['meal_selections'][event_name] = meal_choice[guest_name]
1466
+ else:
1467
+ guest['rsvp_by_event'][event_name] = 'No'
1468
+
1469
+ def _render_summary_stats(self, all_guests, config):
1470
+ """Render summary statistics"""
1471
+ st.markdown("### Summary Statistics")
1472
+
1473
+ col1, col2, col3, col4, col5 = st.columns(5)
1474
+
1475
+ with col1:
1476
+ total_guests = len(all_guests)
1477
+ st.metric("Total Guests", total_guests)
1478
+
1479
+ with col2:
1480
+ named_guests = len([g for g in all_guests if g['type'] == 'Named Guest'])
1481
+ st.metric("Named Guests", named_guests)
1482
+
1483
+ with col3:
1484
+ plus_ones = len([g for g in all_guests if 'Plus One' in g['type']])
1485
+ st.metric("Plus Ones", plus_ones)
1486
+
1487
+ with col4:
1488
+ confirmed = len([g for g in all_guests if any(status == 'Yes' for status in g.get('rsvp_by_event', {}).values())])
1489
+ st.metric("Confirmed Attendees", confirmed)
1490
+
1491
+ with col5:
1492
+ # Count confirmed plus ones (plus ones who have confirmed to at least one event)
1493
+ confirmed_plus_ones = len([g for g in all_guests if 'Plus One' in g['type'] and any(status == 'Yes' for status in g.get('rsvp_by_event', {}).values())])
1494
+ st.metric("Confirmed Plus Ones", confirmed_plus_ones)
1495
+
1496
+ # Event attendance breakdown
1497
+ st.markdown("#### Event Attendance")
1498
+ wedding_events = config.get('wedding_events', [])
1499
+
1500
+ event_stats = []
1501
+ for event in wedding_events:
1502
+ event_name = event.get('name', '')
1503
+ yes_count = len([g for g in all_guests if g.get('rsvp_by_event', {}).get(event_name) == 'Yes'])
1504
+ no_count = len([g for g in all_guests if g.get('rsvp_by_event', {}).get(event_name) == 'No'])
1505
+ pending_count = len([g for g in all_guests if g.get('rsvp_by_event', {}).get(event_name) == 'Pending'])
1506
+
1507
+ event_stats.append({
1508
+ 'Event': event_name,
1509
+ 'Yes': yes_count,
1510
+ 'No': no_count,
1511
+ 'Pending': pending_count
1512
+ })
1513
+
1514
+ if event_stats:
1515
+ stats_df = pd.DataFrame(event_stats)
1516
+ st.dataframe(stats_df, use_container_width=True, hide_index=True)
1517
+
1518
+ # Meal choice breakdown
1519
+ st.markdown("#### Meal Choices")
1520
+ wedding_events = config.get('wedding_events', [])
1521
+
1522
+ # Get events that require meal choices
1523
+ meal_events = [event for event in wedding_events if event.get('requires_meal_choice', False)]
1524
+
1525
+ if meal_events:
1526
+ for event in meal_events:
1527
+ event_name = event.get('name', '')
1528
+ meal_options = event.get('meal_options', [])
1529
+
1530
+ if meal_options:
1531
+ st.markdown(f"**{event_name}**")
1532
+
1533
+ # Count meal choices for this event
1534
+ meal_counts = {option: 0 for option in meal_options}
1535
+ meal_counts['Not Selected'] = 0
1536
+ outdated_choices = {}
1537
+
1538
+ for guest in all_guests:
1539
+ meal_selections = guest.get('meal_selections', {})
1540
+ selected_meal = meal_selections.get(event_name, 'Not Selected')
1541
+ if selected_meal in meal_counts:
1542
+ meal_counts[selected_meal] += 1
1543
+ elif selected_meal != 'Not Selected':
1544
+ # This is an outdated meal choice
1545
+ if selected_meal not in outdated_choices:
1546
+ outdated_choices[selected_meal] = []
1547
+ outdated_choices[selected_meal].append(guest['display_name'])
1548
+ else:
1549
+ meal_counts['Not Selected'] += 1
1550
+
1551
+ # Display meal choice counts
1552
+ col1, col2, col3, col4 = st.columns(4)
1553
+ cols = [col1, col2, col3, col4]
1554
+
1555
+ for i, (meal, count) in enumerate(meal_counts.items()):
1556
+ if i < len(cols):
1557
+ with cols[i]:
1558
+ st.metric(meal, count)
1559
+
1560
+ # Show outdated meal choices warning
1561
+ if outdated_choices:
1562
+ st.warning("⚠️ **Outdated Meal Choices Detected!**")
1563
+ st.markdown("The following guests selected meal options that are no longer available on the current menu:")
1564
+
1565
+ for outdated_meal, guests in outdated_choices.items():
1566
+ with st.expander(f"🔍 {outdated_meal} ({len(guests)} guests)", expanded=False):
1567
+ st.markdown("**Guests who selected this outdated option:**")
1568
+
1569
+ # Create a table for better formatting
1570
+ guest_data = []
1571
+ for guest_name in guests:
1572
+ # Find the guest to get their phone number
1573
+ guest_info = next((g for g in all_guests if g['display_name'] == guest_name), None)
1574
+
1575
+ # Get phone number - prefer individual phone, fall back to group contact
1576
+ phone = 'No phone provided'
1577
+ if guest_info:
1578
+ # Check if guest has individual phone number
1579
+ individual_phone = guest_info.get('phone', '')
1580
+ if individual_phone and individual_phone.strip() and individual_phone != 'No phone provided':
1581
+ phone = individual_phone
1582
+ else:
1583
+ # Use group contact phone number
1584
+ group_name = guest_info.get('group_name', '')
1585
+ if group_name and self.rsvp_data and group_name in self.rsvp_data:
1586
+ group_phone = self.rsvp_data[group_name].get('phone_number', '')
1587
+ if group_phone and group_phone.strip() and group_phone != 'No phone provided':
1588
+ phone = f"{group_phone} (Group Contact)"
1589
+
1590
+ guest_data.append({
1591
+ 'Name': guest_name,
1592
+ 'Phone': phone
1593
+ })
1594
+
1595
+ if guest_data:
1596
+ guest_df = pd.DataFrame(guest_data)
1597
+ st.dataframe(guest_df, use_container_width=True, hide_index=True)
1598
+
1599
+ st.markdown("**Action Required:**")
1600
+ 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)}")
1601
+ else:
1602
+ st.info("No events require meal choices.")
requirements.txt CHANGED
@@ -1,3 +1,12 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
 
 
 
 
 
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
11
+ streamlit-authenticator>=0.2.3
12
+ PyYAML>=6.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()