diff --git "a/vendors.py" "b/vendors.py" new file mode 100644--- /dev/null +++ "b/vendors.py" @@ -0,0 +1,3216 @@ +import streamlit as st +import json +from datetime import datetime +from config_manager import ConfigManager + +class VendorManager: + def __init__(self): + self.config_manager = ConfigManager() + + def create_initial_payment_history(self, deposit_paid, deposit_paid_date, payment_method, payment_notes, is_credit=False): + """Create initial payment history with deposit if any""" + payment_history = [] + if deposit_paid > 0: + payment_type = 'credit' if is_credit else 'deposit' + notes_prefix = "Initial credit/reimbursement" if is_credit else "Initial payment" + payment_record = { + 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"), + 'amount': deposit_paid, + 'date': deposit_paid_date.isoformat() if deposit_paid_date else datetime.now().isoformat(), + 'method': payment_method, + 'notes': f"{notes_prefix}. {payment_notes}" if payment_notes else notes_prefix, + 'type': payment_type + } + payment_history.append(payment_record) + return payment_history + + def update_deposit_in_payment_history(self, vendor, new_deposit_paid, new_deposit_paid_date, payment_method, payment_notes): + """Update deposit in payment history when deposit amount or date changes""" + payment_history = vendor.get('payment_history', []) + + # Find existing deposit record + deposit_record = None + for record in payment_history: + if record.get('type') == 'deposit': + deposit_record = record + break + + # Remove old deposit record if it exists + if deposit_record: + payment_history.remove(deposit_record) + + # Add new deposit record if deposit amount > 0 + if new_deposit_paid > 0: + new_deposit_record = { + 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"), + 'amount': new_deposit_paid, + 'date': new_deposit_paid_date.isoformat() if new_deposit_paid_date else datetime.now().isoformat(), + 'method': payment_method, + 'notes': f"Payment. {payment_notes}" if payment_notes else "Payment", + 'type': 'deposit' + } + payment_history.append(new_deposit_record) + + return payment_history + + def migrate_deposits_to_payment_history(self, vendors): + """Migrate existing deposits to payment history for vendors that don't have them""" + vendors_updated = False + for vendor in vendors: + if 'payment_history' not in vendor: + vendor['payment_history'] = [] + + # Check if deposit exists but not in payment history + deposit_paid = vendor.get('deposit_paid', 0) + if deposit_paid > 0: + # Check if deposit is already in payment history + has_deposit_in_history = any(record.get('type') == 'deposit' for record in vendor.get('payment_history', [])) + + if not has_deposit_in_history: + # Add deposit to payment history + deposit_record = { + 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"), + 'amount': deposit_paid, + 'date': vendor.get('deposit_paid_date', datetime.now().isoformat()), + 'method': vendor.get('payment_method', 'Not Specified'), + 'notes': f"Payment. {vendor.get('payment_notes', '')}" if vendor.get('payment_notes') else "Payment", + 'type': 'deposit' + } + vendor['payment_history'].append(deposit_record) + vendors_updated = True + + return vendors_updated + + def sync_paid_installments_to_payment_history(self, vendors): + """Sync paid installments to payment history for accurate payment tracking""" + vendors_updated = False + for vendor in vendors: + payment_installments = vendor.get('payment_installments', []) + payment_history = vendor.get('payment_history', []) + + if not payment_installments: + continue + + # Get existing installment payment IDs and their records + existing_installment_records = {} + for record in payment_history: + if record.get('type') == 'installment' and record.get('installment_id'): + existing_installment_records[record.get('installment_id')] = record + + # Check each installment + for i, installment in enumerate(payment_installments): + installment_id = f"installment_{i+1}_{vendor.get('id', 'unknown')}" + is_paid = installment.get('paid', False) + installment_amount = installment.get('amount', 0) + + if is_paid: + # Installment is marked as paid - add to payment history if not already there + if installment_id not in existing_installment_records: + # Check if this payment amount already exists in payment history + # to avoid creating duplicate payments + amount_already_recorded = False + for record in payment_history: + if (record.get('amount') == installment_amount and + record.get('type') in ['payment', 'deposit', 'installment'] and + not record.get('installment_id')): + amount_already_recorded = True + break + + # Only create installment record if the amount hasn't been recorded yet + # AND if there's a paid_date (indicating the payment was actually made) + if not amount_already_recorded and installment.get('paid_date'): + installment_record = { + 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"), + 'installment_id': installment_id, + 'amount': installment_amount, + 'date': installment.get('paid_date') or installment.get('due_date') or datetime.now().isoformat(), + 'method': vendor.get('payment_method', 'Not Specified'), + 'notes': f"Installment {i+1}", + 'type': 'installment' + } + payment_history.append(installment_record) + vendors_updated = True + else: + # Installment is marked as unpaid - remove from payment history if it exists + if installment_id in existing_installment_records: + payment_history = [record for record in payment_history if record.get('installment_id') != installment_id] + vendors_updated = True + + # Update the vendor's payment history + vendor['payment_history'] = payment_history + + return vendors_updated + + def cleanup_duplicate_payments(self, vendors): + """Remove duplicate payment records that may have been created""" + vendors_updated = False + for vendor in vendors: + payment_history = vendor.get('payment_history', []) + if not payment_history: + continue + + # Group payments by amount, date, and method to find true duplicates + # Only consider payments with the same amount, date, method, and type as potential duplicates + payment_groups = {} + for record in payment_history: + amount = record.get('amount', 0) + date = record.get('date', '') + method = record.get('method', '') + payment_type = record.get('type', 'payment') + # Use a more specific key that includes method and type to avoid false positives + key = f"{amount}_{date}_{method}_{payment_type}" + + if key not in payment_groups: + payment_groups[key] = [] + payment_groups[key].append(record) + + # Remove only true duplicates - payments with identical amount, date, method, and type + cleaned_history = [] + for key, records in payment_groups.items(): + if len(records) > 1: + # Keep the first record, remove true duplicates + # Only mark as updated if we actually found duplicates + cleaned_history.append(records[0]) + vendors_updated = True + else: + cleaned_history.append(records[0]) + + vendor['payment_history'] = cleaned_history + + return vendors_updated + + def check_payment_due_dates(self, vendors): + """Check for payment due dates and show notifications""" + from datetime import datetime, date, timedelta + + today = date.today() + past_due_vendors = [] + due_today_vendors = [] + due_soon_vendors = [] + + for vendor in vendors: + payment_installments = vendor.get('payment_installments', []) + + if payment_installments: + # Check each installment + for i, installment in enumerate(payment_installments): + if not installment.get('paid', False): # Only check unpaid installments + due_date_str = installment.get('due_date') + if due_date_str: + try: + due_date = datetime.fromisoformat(due_date_str).date() + days_until_due = (due_date - today).days + + vendor_info = { + 'name': vendor.get('name', ''), + 'installment_num': i + 1, + 'amount': installment.get('amount', 0), + 'due_date': due_date_str + } + + if days_until_due < 0: + past_due_vendors.append(vendor_info) + elif days_until_due == 0: + due_today_vendors.append(vendor_info) + elif days_until_due <= 30: # Due within a month + due_soon_vendors.append(vendor_info) + except: + continue + else: + # Check single payment due date + payment_due_date_str = vendor.get('payment_due_date') + if payment_due_date_str: + try: + due_date = datetime.fromisoformat(payment_due_date_str).date() + days_until_due = (due_date - today).days + + # Only show if not fully paid + total_cost = vendor.get('total_cost', vendor.get('cost', 0)) + deposit_paid = vendor.get('deposit_paid', 0) + + if deposit_paid < total_cost: # Not fully paid + vendor_info = { + 'name': vendor.get('name', ''), + 'installment_num': None, + 'amount': total_cost - deposit_paid, + 'due_date': payment_due_date_str + } + + if days_until_due < 0: + past_due_vendors.append(vendor_info) + elif days_until_due == 0: + due_today_vendors.append(vendor_info) + elif days_until_due <= 30: + due_soon_vendors.append(vendor_info) + except: + continue + + # Show notifications + if past_due_vendors: + st.error("🚨 **PAST DUE PAYMENTS**") + for vendor_info in past_due_vendors: + if vendor_info['installment_num']: + st.error(f"**{vendor_info['name']}** - Installment {vendor_info['installment_num']}: ${vendor_info['amount']:,.0f} (was due {vendor_info['due_date']})") + else: + st.error(f"**{vendor_info['name']}** - ${vendor_info['amount']:,.0f} (was due {vendor_info['due_date']})") + + if due_today_vendors: + st.warning("⚠️ **PAYMENTS DUE TODAY**") + for vendor_info in due_today_vendors: + if vendor_info['installment_num']: + st.warning(f"**{vendor_info['name']}** - Installment {vendor_info['installment_num']}: ${vendor_info['amount']:,.0f}") + else: + st.warning(f"**{vendor_info['name']}** - ${vendor_info['amount']:,.0f}") + + if due_soon_vendors: + st.info("📅 **PAYMENTS DUE SOON** (within 30 days)") + for vendor_info in due_soon_vendors: + # Calculate days until due for better formatting + try: + due_date = datetime.fromisoformat(vendor_info['due_date']).date() + days_until = (due_date - today).days + if days_until == 1: + due_text = "tomorrow" + elif days_until <= 30: + due_text = f"in {days_until} days" + else: + due_text = f"on {vendor_info['due_date']}" + except: + due_text = f"on {vendor_info['due_date']}" + + if vendor_info['installment_num']: + st.info(f"**{vendor_info['name']}** - Installment {vendor_info['installment_num']}: ${vendor_info['amount']:,.0f} (due {due_text})") + else: + st.info(f"**{vendor_info['name']}** - ${vendor_info['amount']:,.0f} (due {due_text})") + + # Add spacing if any notifications were shown + if past_due_vendors or due_today_vendors or due_soon_vendors: + st.markdown("---") + + def show_upcoming_payments_summary(self, vendors): + """Show a summary of upcoming payments due within a month""" + from datetime import datetime, date, timedelta + + today = date.today() + upcoming_payments = [] + + for vendor in vendors: + payment_installments = vendor.get('payment_installments', []) + + if payment_installments: + # Check each installment + for i, installment in enumerate(payment_installments): + if not installment.get('paid', False): # Only check unpaid installments + due_date_str = installment.get('due_date') + if due_date_str: + try: + due_date = datetime.fromisoformat(due_date_str).date() + days_until_due = (due_date - today).days + + if 0 <= days_until_due <= 30: # Due within a month (including today) + upcoming_payments.append({ + 'vendor_name': vendor.get('name', ''), + 'installment_num': i + 1, + 'amount': installment.get('amount', 0), + 'due_date': due_date, + 'days_until': days_until_due, + 'is_installment': True + }) + except: + continue + else: + # Check single payment due date + payment_due_date_str = vendor.get('payment_due_date') + if payment_due_date_str: + try: + due_date = datetime.fromisoformat(payment_due_date_str).date() + days_until_due = (due_date - today).days + + # Only show if not fully paid + total_cost = vendor.get('total_cost', vendor.get('cost', 0)) + deposit_paid = vendor.get('deposit_paid', 0) + + if deposit_paid < total_cost and 0 <= days_until_due <= 30: + upcoming_payments.append({ + 'vendor_name': vendor.get('name', ''), + 'installment_num': None, + 'amount': total_cost - deposit_paid, + 'due_date': due_date, + 'days_until': days_until_due, + 'is_installment': False + }) + except: + continue + + # Sort by days until due (most urgent first) + upcoming_payments.sort(key=lambda x: x['days_until']) + + # Display summary if there are upcoming payments + if upcoming_payments: + st.markdown("### 🔔 Upcoming Payments (Next 30 Days)") + + # Create a simple table + import pandas as pd + + # Prepare data for the table + table_data = [] + for payment in upcoming_payments: + # Format payment description + if payment['is_installment']: + payment_desc = f"{payment['vendor_name']} - Installment {payment['installment_num']}" + else: + payment_desc = f"{payment['vendor_name']} - Final Payment" + + # Format due date + if payment['days_until'] == 0: + due_text = "🟠 Today" + elif payment['days_until'] == 1: + due_text = "🟡 Tomorrow" + elif payment['days_until'] <= 3: + due_text = f"🟡 {payment['days_until']} days" + else: + due_text = f"🟢 {payment['days_until']} days" + + table_data.append({ + 'Vendor & Payment': payment_desc, + 'Amount': f"${payment['amount']:,.0f}", + 'Due': due_text + }) + + # Create and display the table + df = pd.DataFrame(table_data) + st.dataframe(df, use_container_width=True, hide_index=True) + + def render(self, config): + st.markdown("## Vendor & Item Management") + + # Load vendors + vendors = self.config_manager.load_json_data('vendors.json') + + # Show upcoming payments summary at the top + self.show_upcoming_payments_summary(vendors) + + # Ensure all vendors have payment_history field (backward compatibility) + vendors_updated = False + for vendor in vendors: + if 'payment_history' not in vendor: + vendor['payment_history'] = [] + vendors_updated = True + + # Migrate existing deposits to payment history + if self.migrate_deposits_to_payment_history(vendors): + vendors_updated = True + + # Sync paid installments to payment history + # DISABLED: This function was causing deleted payments to be recreated + # if self.sync_paid_installments_to_payment_history(vendors): + # vendors_updated = True + + # Clean up any duplicate payment records + # DISABLED: This function was causing deleted payments to be recreated + # if self.cleanup_duplicate_payments(vendors): + # vendors_updated = True + + # Save updated vendors if any were modified + if vendors_updated: + self.config_manager.save_json_data('vendors.json', vendors) + + # Payment notifications are now handled in show_upcoming_payments_summary + + # Add new vendor/item section + with st.expander("Add New Vendor or Item", expanded=False): + self.render_vendor_form() + + # Vendor/Item summary + if vendors: + # Separate vendors and items + vendor_items = [v for v in vendors if v.get('type', 'Vendor/Service') == 'Vendor/Service'] + purchased_items = [v for v in vendors if v.get('type') == 'Item/Purchase'] + + col1, col2, col3, col4, col5 = st.columns(5) + with col1: + st.metric("Total Vendors", len(vendor_items)) + with col2: + st.metric("Total Items", len(purchased_items)) + with col3: + booked_count = len([v for v in vendor_items if v.get('status') == 'Booked']) + delivered_count = len([v for v in purchased_items if v.get('status') == 'Delivered']) + st.metric("Booked/Delivered", booked_count + delivered_count) + with col4: + pending_count = len([v for v in vendor_items if v.get('status') == 'Pending']) + ordered_count = len([v for v in purchased_items if v.get('status') == 'Ordered']) + st.metric("Pending/Ordered", pending_count + ordered_count) + with col5: + # Calculate both cost metrics + total_estimated_cost = sum([v.get('total_cost', v.get('cost', 0)) for v in vendors]) + total_actualized_cost = self.calculate_actualized_cost(vendors) + st.metric("Total Estimated", f"${total_estimated_cost:,.0f}") + + # Add prominent cost breakdown section + st.markdown("---") + col1, col2, col3 = st.columns(3) + + with col1: + st.markdown("### 💰 Total Estimated Cost") + st.markdown(f"**${total_estimated_cost:,.0f}**") + st.caption("All vendors & items regardless of status") + + with col2: + st.markdown("### ✅ Actualized Cost") + st.markdown(f"**${total_actualized_cost:,.0f}**") + st.caption("Only booked/ordered/delivered items") + + with col3: + pending_cost = total_estimated_cost - total_actualized_cost + st.markdown("### ⏳ Pending Cost") + st.markdown(f"**${pending_cost:,.0f}**") + st.caption("Estimated costs not yet confirmed") + + # Event-based breakdown + self.render_event_breakdown(vendors) + + # Payer breakdown + self.render_payer_breakdown(vendors) + + # Filters + col1, col2, col3, col4, col5, col6 = st.columns(6) + + with col1: + type_filter = st.selectbox("Filter by Type", ["All", "Vendor/Service", "Item/Purchase"]) + + with col2: + category_filter = st.selectbox("Filter by Category", ["All"] + self.get_vendor_categories(vendors)) + + with col3: + status_filter = st.selectbox("Filter by Status", ["All", "Booked", "Pending", "Researching", "Ordered", "Shipped", "Delivered", "Not Needed"]) + + with col4: + # Event filter + config = self.config_manager.load_config() + events = config.get('wedding_events', []) + event_names = [event['name'] for event in events] + event_filter = st.selectbox("Filter by Event", ["All"] + event_names) + + with col5: + payer_filter = st.selectbox("Filter by Payer", ["All"] + self.get_unique_payers(vendors)) + + with col6: + search_term = st.text_input("Search", placeholder="Search by name or service") + + # Filter vendors + filtered_vendors = self.filter_vendors(vendors, type_filter, category_filter, status_filter, event_filter, payer_filter, search_term) + + # Display vendors/items + if filtered_vendors: + st.markdown(f"### Vendors & Items ({len(filtered_vendors)} of {len(vendors)} total)") + + # View toggle + view_mode = st.radio("View Mode", ["Card View", "Table View"], horizontal=True) + + if view_mode == "Table View": + self.render_vendor_table(filtered_vendors) + else: + for vendor in filtered_vendors: + self.render_vendor_card(vendor) + else: + st.info("No vendors or items found. Add your first vendor or item above!") + + def render_vendor_form(self): + # Type selection outside of form + item_type = st.radio("Type", ["Vendor/Service", "Item/Purchase"], horizontal=True) + + if item_type == "Vendor/Service": + # Event selection outside of form for vendors + config = self.config_manager.load_config() + events = config.get('wedding_events', []) + event_names = [event['name'] for event in events] + + if event_names: + selected_events = st.multiselect( + "Events *", + event_names, + help="Select which events this vendor will be providing services for. Vendors can be associated with multiple events." + ) + else: + selected_events = [] + st.info("No events configured yet. Please add events in the wedding configuration first.") + + # Single cost structure for all vendors - cost is split equally across events + self.render_vendor_service_form(selected_events) + else: + # Event selection outside of form for items + config = self.config_manager.load_config() + events = config.get('wedding_events', []) + event_names = [event['name'] for event in events] + + if event_names: + selected_events = st.multiselect( + "Events *", + event_names, + help="Select which events this item will be used for. Items can be associated with multiple events." + ) + else: + selected_events = [] + st.info("No events configured yet. Please add events in the wedding configuration first.") + + self.render_item_purchase_form(selected_events) + + def render_vendor_service_form(self, selected_events): + # Payment installments selection outside of form + st.markdown("#### Payment Structure") + use_installments = st.checkbox("Use payment installments", help="Check this if payments are made in multiple installments", key="flat_use_installments") + + # Number of installments outside of form for dynamic updates + num_installments = 2 + if use_installments: + num_installments = st.number_input("Number of Installments", min_value=2, max_value=10, value=2, step=1, key="num_installments_flat") + + with st.form("vendor_service_form"): + col1, col2 = st.columns(2) + + with col1: + name = st.text_input("Vendor Name *", placeholder="Enter vendor name") + category = st.multiselect("Categories", [ + "Venue", "Catering", "Photography", "Videography", "Music/DJ", "Entertainment", + "Flowers", "Decor", "Attire", "Hair & Makeup", "Transportation", + "Invitations", "Cake", "Officiant", "Lodging", "Other" + ], help="Select one or more categories that apply to this vendor") + contact_person = st.text_input("Contact Person", placeholder="Enter contact person name") + phone = st.text_input("Phone", placeholder="Enter phone number") + + with col2: + email = st.text_input("Email", placeholder="Enter email address") + website = st.text_input("Website", placeholder="Enter website URL") + address = st.text_area("Address", placeholder="Enter full address") + status = st.selectbox("Status", ["Researching", "Contacted", "Pending", "Booked", "Not Needed"]) + + # Cost and payment information + st.markdown("#### Cost & Payment Information") + st.info("💡 **Cost Allocation:** The total cost will be split equally across all selected events.") + + col3, col4 = st.columns(2) + with col3: + total_cost = st.number_input("Total Cost", min_value=0.0, value=0.0, step=100.0) + deposit_paid = st.number_input("Amount Paid", min_value=0.0, value=0.0, step=100.0) + deposit_paid_date = st.date_input("Payment Date", value=None, help="When was this payment made?") + payment_method = st.selectbox("Payment Method", ["Not Specified", "Check", "Credit Card", "Bank Transfer", "Cash", "PayPal", "Venmo", "Zelle", "Reimbursement", "Credit", "Other"]) + with col4: + # Only show payment due date if not using installments + if not use_installments: + payment_due_date = st.date_input("Payment Due Date", value=None) + else: + payment_due_date = None + payer = st.text_input("Payer", placeholder="Who is paying for this vendor?") + payment_notes = st.text_area("Payment Notes", placeholder="Additional notes about payment method or process", height=100) + + # Initialize payment_installments list + payment_installments = [] + + # Payment installments section (inside form but controlled by outside checkbox) + if use_installments: + st.markdown("#### Payment Installments") + st.info("💡 **Payment Installments:** Set up multiple payment dates and amounts. The system will track each installment and remind you when payments are due.") + + # Create a fixed number of installment inputs (up to 10) + for i in range(10): + if i < num_installments: + st.markdown(f"**Installment {i+1}:**") + col_inst1, col_inst2, col_inst3 = st.columns(3) + + with col_inst1: + installment_amount = st.number_input( + f"Amount {i+1}", + min_value=0.0, + value=(total_cost - deposit_paid)/num_installments if total_cost > 0 else 0.0, + step=100.0, + key=f"flat_installment_amount_{i}" + ) + + with col_inst2: + installment_date = st.date_input( + f"Due Date {i+1}", + value=None, + key=f"flat_installment_date_{i}" + ) + + with col_inst3: + installment_paid = st.checkbox( + f"Paid {i+1}", + value=False, + key=f"flat_installment_paid_{i}" + ) + + payment_installments.append({ + 'amount': installment_amount, + 'due_date': installment_date.isoformat() if installment_date else None, + 'paid': installment_paid, + 'paid_date': None + }) + else: + # Single payment structure + payment_installments = [{ + 'amount': total_cost, + 'due_date': payment_due_date.isoformat() if payment_due_date else None, + 'paid': deposit_paid >= total_cost if total_cost > 0 else False, + 'paid_date': None + }] + + # Set the same payer for all events (cost is split equally across events) + event_costs = {} + event_payers = {} + for event in selected_events: + event_costs[event] = 0 # Cost is stored in total_cost, not per-event + event_payers[event] = payer + + notes = st.text_area("Notes", placeholder="Additional notes about this vendor") + submitted = st.form_submit_button("Add Vendor", type="primary") + + if submitted: + if name and selected_events: + new_vendor = { + 'id': datetime.now().strftime("%Y%m%d_%H%M%S"), + 'name': name, + 'type': 'Vendor/Service', + 'categories': category, # Store as list of categories + 'category': category[0] if category else '', # Keep backward compatibility + 'contact_person': contact_person, + 'phone': phone, + 'email': email, + 'website': website, + 'address': address, + 'status': status, + 'events': selected_events, + 'event_costs': event_costs, + 'event_payers': event_payers, + 'total_cost': total_cost, + 'deposit_paid': deposit_paid, + 'deposit_paid_date': deposit_paid_date.isoformat() if deposit_paid_date else None, + 'payment_due_date': payment_due_date.isoformat() if payment_due_date else None, + 'payment_method': payment_method, + 'payment_notes': payment_notes, + 'payment_installments': payment_installments, + 'payment_history': self.create_initial_payment_history(deposit_paid, deposit_paid_date, payment_method, payment_notes), + 'notes': notes, + 'created_date': datetime.now().isoformat() + } + + # Load existing vendors and add new one + vendors = self.config_manager.load_json_data('vendors.json') + vendors.append(new_vendor) + + if self.config_manager.save_json_data('vendors.json', vendors): + st.success("Vendor added successfully!") + st.rerun() + else: + st.error("Error saving vendor") + elif not name: + st.error("Please enter a vendor name") + elif not selected_events: + st.error("Please select at least one event for this vendor") + + + def render_item_purchase_form(self, selected_events): + # Payment installments selection outside of form + st.markdown("#### Payment Structure") + use_installments = st.checkbox("Use payment installments", help="Check this if payments are made in multiple installments", key="item_use_installments") + + # Number of installments outside of form for dynamic updates + num_installments = 2 + if use_installments: + num_installments = st.number_input("Number of Installments", min_value=2, max_value=10, value=2, step=1, key="num_installments_item") + + with st.form("item_purchase_form"): + col1, col2 = st.columns(2) + + with col1: + name = st.text_input("Item Name *", placeholder="Enter item name") + category = st.multiselect("Categories", [ + "Decorations", "Centerpieces", "Favors", "Signage", "Linens", + "Tableware", "Lighting", "Flowers", "Attire", "Accessories", + "Invitations", "Stationery", "Gifts", "Other" + ], help="Select one or more categories that apply to this item") + quantity = st.number_input("Quantity", min_value=1, value=1, step=1) + unit_cost = st.number_input("Unit Cost", min_value=0.0, value=0.0, step=100.0, format="%.2f") + + with col2: + status = st.selectbox("Status", ["Researching", "Ordered", "Shipped", "Delivered", "Not Needed"]) + # Calculate total cost from quantity and unit cost + total_item_cost = quantity * unit_cost + st.info(f"**Total Item Cost: ${total_item_cost:,.2f}**") + + # Seller contact information section + st.markdown("#### Seller Contact Information") + col3, col4 = st.columns(2) + + with col3: + seller_phone = st.text_input("Seller Phone", placeholder="Enter seller phone number") + seller_email = st.text_input("Seller Email", placeholder="Enter seller email address") + + with col4: + seller_website = st.text_input("Seller Website", placeholder="Enter seller website URL") + + # Cost and payment information + st.markdown("#### Cost & Payment Information") + st.info("💡 **Cost Allocation:** The total cost will be split equally across all selected events.") + + col3, col4 = st.columns(2) + with col3: + total_cost = total_item_cost + st.number_input("Total Cost", value=total_cost, disabled=True, help="Calculated from quantity × unit cost") + deposit_paid = st.number_input("Amount Paid", min_value=0.0, value=0.0, step=100.0) + deposit_paid_date = st.date_input("Payment Date", value=None, help="When was this payment made?") + payment_method = st.selectbox("Payment Method", ["Not Specified", "Check", "Credit Card", "Bank Transfer", "Cash", "PayPal", "Venmo", "Zelle", "Reimbursement", "Credit", "Other"]) + with col4: + # Only show payment due date if not using installments + if not use_installments: + payment_due_date = st.date_input("Payment Due Date", value=None) + else: + payment_due_date = None + payer = st.text_input("Who paid for this item?", placeholder="Who is paying for this item?") + payment_notes = st.text_area("Payment Notes", placeholder="Additional notes about payment method or process", height=100) + + # Initialize payment_installments list + payment_installments = [] + + # Payment installments section (inside form but controlled by outside checkbox) + if use_installments: + st.markdown("#### Payment Installments") + st.info("💡 **Payment Installments:** Set up multiple payment dates and amounts. The system will track each installment and remind you when payments are due.") + + # Create a fixed number of installment inputs (up to 10) + for i in range(10): + if i < num_installments: + st.markdown(f"**Installment {i+1}:**") + col_inst1, col_inst2, col_inst3 = st.columns(3) + + with col_inst1: + installment_amount = st.number_input( + f"Amount {i+1}", + min_value=0.0, + value=(total_cost - deposit_paid)/num_installments if total_cost > 0 else 0.0, + step=100.0, + key=f"item_installment_amount_{i}" + ) + + with col_inst2: + installment_date = st.date_input( + f"Due Date {i+1}", + value=None, + key=f"item_installment_date_{i}" + ) + + with col_inst3: + installment_paid = st.checkbox( + f"Paid {i+1}", + value=False, + key=f"item_installment_paid_{i}" + ) + + payment_installments.append({ + 'amount': installment_amount, + 'due_date': installment_date.isoformat() if installment_date else None, + 'paid': installment_paid, + 'paid_date': None + }) + else: + # Single payment structure + payment_installments = [{ + 'amount': total_cost, + 'due_date': payment_due_date.isoformat() if payment_due_date else None, + 'paid': deposit_paid >= total_cost if total_cost > 0 else False, + 'paid_date': None + }] + + # Set the same payer for all events (cost is split equally across events) + event_costs = {} + event_payers = {} + for event in selected_events: + event_costs[event] = 0 # Cost is stored in total_cost, not per-event + event_payers[event] = payer + + notes = st.text_area("Notes", placeholder="Additional notes about this item") + submitted = st.form_submit_button("Add Item", type="primary") + + if submitted: + if name and selected_events: + new_vendor = { + 'id': datetime.now().strftime("%Y%m%d_%H%M%S"), + 'name': name, + 'type': 'Item/Purchase', + 'categories': category, # Store as list of categories + 'category': category[0] if category else '', # Keep backward compatibility + 'contact_person': "", # Items don't have contact person + 'phone': "", # Items don't have phone + 'email': "", # Items don't have email + 'website': "", # Items don't have website + 'address': "", # Items don't have address + 'seller_phone': seller_phone, # Seller contact info + 'seller_email': seller_email, # Seller contact info + 'seller_website': seller_website, # Seller contact info + 'status': status, + 'events': selected_events, + 'event_costs': event_costs, + 'event_payers': event_payers, + 'total_cost': total_cost, + 'deposit_paid': deposit_paid, + 'deposit_paid_date': deposit_paid_date.isoformat() if deposit_paid_date else None, + 'payment_due_date': payment_due_date.isoformat() if payment_due_date else None, + 'payment_method': payment_method, + 'payment_notes': payment_notes, + 'payment_installments': payment_installments, + 'payment_history': self.create_initial_payment_history(deposit_paid, deposit_paid_date, payment_method, payment_notes), + 'notes': notes, + 'quantity': quantity, + 'unit_cost': unit_cost, + 'created_date': datetime.now().isoformat() + } + + # Load existing vendors and add new one + vendors = self.config_manager.load_json_data('vendors.json') + vendors.append(new_vendor) + + if self.config_manager.save_json_data('vendors.json', vendors): + st.success("Item added successfully!") + st.rerun() + else: + st.error("Error saving item") + elif not name: + st.error("Please enter an item name") + elif not selected_events: + st.error("Please select at least one event for this item") + + def filter_vendors(self, vendors, type_filter, category_filter, status_filter, event_filter, payer_filter, search_term): + filtered = vendors.copy() + + # Type filter + if type_filter != "All": + filtered = [vendor for vendor in filtered if vendor.get('type', 'Vendor/Service') == type_filter] + + # Category filter - check both single category and multiple categories + if category_filter != "All": + filtered = [vendor for vendor in filtered if + vendor.get('category') == category_filter or + category_filter in vendor.get('categories', [])] + + # Status filter + if status_filter != "All": + filtered = [vendor for vendor in filtered if vendor.get('status') == status_filter] + + # Event filter + if event_filter != "All": + filtered = [vendor for vendor in filtered if event_filter in vendor.get('events', [])] + + # Payer filter + if payer_filter != "All": + filtered = [vendor for vendor in filtered if self.get_payer(vendor) == payer_filter] + + # Search filter + if search_term: + search_lower = search_term.lower() + filtered = [vendor for vendor in filtered if + search_lower in vendor.get('name', '').lower() or + search_lower in vendor.get('category', '').lower() or + search_lower in vendor.get('contact_person', '').lower() or + any(search_lower in cat.lower() for cat in vendor.get('categories', []) if cat)] + + return filtered + + def get_vendor_categories(self, vendors): + categories = set() + for vendor in vendors: + # Check single category field (backward compatibility) + category = vendor.get('category', '') + if category: + categories.add(category) + + # Check multiple categories field + categories_list = vendor.get('categories', []) + if categories_list and isinstance(categories_list, list): + for cat in categories_list: + if cat: + categories.add(cat) + return sorted(list(categories)) + + def get_unique_payers(self, vendors): + """Get unique list of payers from vendor data""" + payers = set() + for vendor in vendors: + event_payers = vendor.get('event_payers', {}) + if event_payers: + # Get the payer from any event (all events should have same payer) + payer = list(event_payers.values())[0] + if payer and payer.strip(): + payers.add(payer) + + return sorted(list(payers)) + + def render_event_breakdown(self, vendors): + """Render event-based vendor breakdown""" + config = self.config_manager.load_config() + events = config.get('wedding_events', []) + + if not events: + return + + st.markdown("### Vendors & Items by Event") + st.info("💡 **Cost Allocation:** All vendors and items have their costs split equally across all events they serve. This provides a more accurate per-event cost breakdown.") + + # Create event breakdown data + event_data = {} + for event in events: + event_name = event['name'] + event_vendors = [v for v in vendors if event_name in v.get('events', [])] + + # Calculate cost for this specific event + event_cost = 0 + for vendor in event_vendors: + vendor_type = vendor.get('type', 'Vendor/Service') + vendor_events = vendor.get('events', []) + num_events = len(vendor_events) if vendor_events else 1 + + # All vendors and items now have their cost split equally across events + total_cost = vendor.get('total_cost', vendor.get('cost', 0)) + + # Only include cost if vendor is booked or item is ordered/delivered + if vendor_type == 'Vendor/Service': + if vendor.get('status') == 'Booked': + event_cost += total_cost / num_events + elif vendor_type == 'Item/Purchase': + status = vendor.get('status', 'Researching') + if status in ['Ordered', 'Shipped', 'Delivered']: + event_cost += total_cost / num_events + + # Count booked vendors and delivered items + booked_delivered_count = 0 + for v in event_vendors: + vendor_type = v.get('type', 'Vendor/Service') + if vendor_type == 'Vendor/Service' and v.get('status') == 'Booked': + booked_delivered_count += 1 + elif vendor_type == 'Item/Purchase' and v.get('status') == 'Delivered': + booked_delivered_count += 1 + + event_data[event_name] = { + 'total_vendors': len(event_vendors), + 'booked_vendors': booked_delivered_count, + 'total_cost': event_cost + } + + # Display event breakdown in a table + import pandas as pd + + # Create table data + table_data = [] + for event_name, data in event_data.items(): + table_data.append({ + 'Event': event_name, + 'Total Vendors/Items': data['total_vendors'], + 'Booked/Delivered': data['booked_vendors'], + 'Total Cost': f"${data['total_cost']:,.0f}" + }) + + if table_data: + df = pd.DataFrame(table_data) + st.dataframe(df, use_container_width=True, hide_index=True) + + def render_event_cost_details(self, events, event_costs, event_payers): + """Render detailed cost and payer information for each event""" + if not events: + return + + # Show the payer if available (all events have same payer) + if event_payers: + payer = list(event_payers.values())[0] # All events have same payer + if payer: + st.markdown(f"**Payer:** {payer}") + + def calculate_actualized_cost(self, vendors): + """Calculate total cost for only booked/ordered/delivered items""" + total_cost = 0 + + for vendor in vendors: + vendor_type = vendor.get('type', 'Vendor/Service') + vendor_total_cost = vendor.get('total_cost', vendor.get('cost', 0)) + + # Only include cost if vendor is booked or item is ordered/delivered + should_include = False + if vendor_type == 'Vendor/Service': + if vendor.get('status') == 'Booked': + should_include = True + elif vendor_type == 'Item/Purchase': + status = vendor.get('status', 'Researching') + if status in ['Ordered', 'Shipped', 'Delivered']: + should_include = True + + if should_include: + total_cost += vendor_total_cost + + return total_cost + + def render_payer_breakdown(self, vendors): + """Render payer-based cost breakdown showing both actualized and estimated costs""" + payer_data = {} + + for vendor in vendors: + event_costs = vendor.get('event_costs', {}) + event_payers = vendor.get('event_payers', {}) + events = vendor.get('events', []) + + vendor_type = vendor.get('type', 'Vendor/Service') + total_cost = vendor.get('total_cost', vendor.get('cost', 0)) + deposit_paid = vendor.get('deposit_paid', 0) + + # Get payer (all events have same payer) + if event_payers: + payer = list(event_payers.values())[0] + else: + payer = 'Not specified' + + # Initialize payer data if not exists + if payer not in payer_data: + payer_data[payer] = { + 'actualized_cost': 0, + 'estimated_cost': 0, + 'total_paid': 0, + 'vendor_count': 0, + 'events': set() + } + + # Always add to estimated cost (all vendors/items regardless of status) + payer_data[payer]['estimated_cost'] += total_cost + + # Calculate total paid from payment history instead of just deposit_paid + payment_history = vendor.get('payment_history', []) + total_paid_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit') + total_credits_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit') + net_paid = total_paid_from_history - total_credits_from_history + + payer_data[payer]['total_paid'] += net_paid + payer_data[payer]['vendor_count'] += 1 + payer_data[payer]['events'].update(events) + + # Only add to actualized cost if vendor is booked or item is ordered/delivered + should_include_actualized = False + if vendor_type == 'Vendor/Service': + if vendor.get('status') == 'Booked': + should_include_actualized = True + elif vendor_type == 'Item/Purchase': + status = vendor.get('status', 'Researching') + if status in ['Ordered', 'Shipped', 'Delivered']: + should_include_actualized = True + + if should_include_actualized: + payer_data[payer]['actualized_cost'] += total_cost + + if payer_data: + st.markdown("### Costs by Payer") + st.info("💡 **Note:** Shows both actualized costs (confirmed) and estimated costs (all items) for each payer. ") + + # Display payer breakdown in a table + import pandas as pd + + # Create table data + table_data = [] + for payer, data in payer_data.items(): + # Calculate balances for both actualized and estimated + actualized_balance = data['actualized_cost'] - data['total_paid'] + estimated_balance = data['estimated_cost'] - data['total_paid'] + pending_cost = data['estimated_cost'] - data['actualized_cost'] + + table_data.append({ + 'Payer': payer, + 'Actualized Cost': f"${data['actualized_cost']:,.0f}", + 'Estimated Cost': f"${data['estimated_cost']:,.0f}", + 'Pending Cost': f"${pending_cost:,.0f}", + 'Net Amount Paid': f"${data['total_paid']:,.0f}", + 'Actualized Balance': f"${actualized_balance:,.0f}", + 'Estimated Balance': f"${estimated_balance:,.0f}", + 'Vendors/Items': data['vendor_count'] + }) + + df = pd.DataFrame(table_data) + st.dataframe(df, use_container_width=True, hide_index=True) + + def render_vendor_table(self, vendors): + """Render vendors in a comprehensive table format""" + import pandas as pd + + # Prepare table data + table_data = [] + for vendor in vendors: + # Basic info + name = vendor.get('name', '') + item_type = vendor.get('type', 'Vendor/Service') + category = self.get_category_display_text(vendor) + status = vendor.get('status', 'Researching') + + # Cost info + total_cost = vendor.get('total_cost', vendor.get('cost', 0)) + deposit_paid = vendor.get('deposit_paid', 0) + # Calculate remaining balance using payment history for accuracy + payment_history = vendor.get('payment_history', []) + total_paid_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit') + total_credits_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit') + remaining_balance = total_cost - total_paid_from_history + total_credits_from_history + + # Payment info + payment_due_date = vendor.get('payment_due_date', '') + payment_method = vendor.get('payment_method', 'Not Specified') + payment_notes = vendor.get('payment_notes', '') + + # Determine payment due display - check for installments first, then single payment + payment_installments = vendor.get('payment_installments', []) + next_installment_due = None + + # Check for upcoming unpaid installments + if payment_installments and len(payment_installments) > 1: + for installment in payment_installments: + if not installment.get('paid', False): + next_installment_due = installment.get('due_date', '') + break + + # Determine what to display + if not payment_due_date and remaining_balance <= 0: + payment_due_display = 'Fully Paid' + elif next_installment_due: + payment_due_display = next_installment_due + elif payment_due_date: + payment_due_display = payment_due_date + else: + payment_due_display = 'Not Set' + + # Payment history + payment_history = vendor.get('payment_history', []) + payment_count = len(payment_history) + + # Event payers + event_payers = vendor.get('event_payers', {}) + payers_str = ', '.join(set(event_payers.values())) if event_payers else 'Not Specified' + + # Contact info + contact_person = vendor.get('contact_person', '') + phone = vendor.get('phone', '') + email = vendor.get('email', '') + website = vendor.get('website', '') + + # Seller contact info for items + seller_phone = vendor.get('seller_phone', '') + seller_email = vendor.get('seller_email', '') + seller_website = vendor.get('seller_website', '') + + # Events + events = vendor.get('events', []) + events_str = ', '.join(events) if events else 'None' + + # Additional fields for items + quantity = vendor.get('quantity', '') + unit_cost = vendor.get('unit_cost', '') + + # Create row data + row_data = { + 'Name': name, + 'Type': item_type, + 'Category': category, + 'Status': status, + 'Events': events_str, + 'Total Cost': f"${total_cost:,.0f}" if total_cost > 0 else 'Not Set', + 'Net Amount Paid': f"${total_paid_from_history:,.0f}" if total_paid_from_history > 0 else '$0', + 'Remaining': f"${remaining_balance:,.0f}" if remaining_balance > 0 else '$0', + 'Payment Due': payment_due_display, + 'Payment Method': payment_method, + 'Payments': f"{payment_count} payment(s)" if payment_count > 0 else 'No payments', + 'Payer(s)': payers_str, + 'Payment Notes': payment_notes, + 'Contact Person': contact_person, + 'Phone': phone, + 'Email': email, + 'Website': website, + 'Seller Phone': seller_phone, + 'Seller Email': seller_email, + 'Seller Website': seller_website + } + + # Add item-specific fields if it's an item + if item_type == 'Item/Purchase': + row_data['Quantity'] = quantity if quantity else '1' + row_data['Unit Cost'] = f"${unit_cost:,.0f}" if unit_cost else 'Not Set' + + table_data.append(row_data) + + if table_data: + df = pd.DataFrame(table_data) + + # Configure column display with proper text wrapping and standard widths + column_config = { + 'Name': st.column_config.TextColumn('Name', width=200, help="Vendor or item name"), + 'Type': st.column_config.TextColumn('Type', width=120, help="Vendor/Service or Item/Purchase"), + 'Category': st.column_config.TextColumn('Category', width=150, help="Category of vendor or item"), + 'Status': st.column_config.TextColumn('Status', width=120, help="Current status"), + 'Events': st.column_config.TextColumn('Events', width=200, help="Associated events"), + 'Total Cost': st.column_config.TextColumn('Total Cost', width=120, help="Total cost amount"), + 'Amount Paid': st.column_config.TextColumn('Amount Paid', width=120, help="Total amount paid"), + 'Remaining': st.column_config.TextColumn('Remaining', width=120, help="Remaining balance"), + 'Payment Due': st.column_config.TextColumn('Due Date', width=120, help="Payment due date"), + 'Payment Method': st.column_config.TextColumn('Payment Method', width=150, help="Payment method used"), + 'Payments': st.column_config.TextColumn('Payments', width=120, help="Number of payments made"), + 'Payment Notes': st.column_config.TextColumn('Payment Notes', width=250, help="Additional payment notes"), + 'Contact Person': st.column_config.TextColumn('Contact', width=150, help="Primary contact person"), + 'Phone': st.column_config.TextColumn('Phone', width=150, help="Phone number"), + 'Email': st.column_config.TextColumn('Email', width=200, help="Email address"), + 'Website': st.column_config.TextColumn('Website', width=150, help="Website URL"), + 'Seller Phone': st.column_config.TextColumn('Seller Phone', width=150, help="Seller phone number"), + 'Seller Email': st.column_config.TextColumn('Seller Email', width=200, help="Seller email address"), + 'Seller Website': st.column_config.TextColumn('Seller Website', width=150, help="Seller website URL"), + 'Payer(s)': st.column_config.TextColumn('Payer(s)', width=150, help="Who is paying for this"), + 'Quantity': st.column_config.TextColumn('Qty', width=80, help="Quantity of items"), + 'Unit Cost': st.column_config.TextColumn('Unit Cost', width=120, help="Cost per unit") + } + + # Use custom HTML table for proper text wrapping + self.render_custom_table(df) + else: + st.info("No vendor data to display") + + def render_custom_table(self, df): + """Render a custom HTML table with proper text wrapping""" + import pandas as pd + + # Create HTML table with proper text wrapping + html = """ + + +
+ + + + """ + + # Add column headers + for col in df.columns: + html += f"" + html += "" + + # Add data rows + for _, row in df.iterrows(): + html += "" + for col in df.columns: + value = str(row[col]) if pd.notna(row[col]) else "" + html += f"" + html += "" + + html += "
{col}
{value}
" + + st.markdown(html, unsafe_allow_html=True) + + def render_vendor_card(self, vendor): + vendor_id = vendor.get('id', '') + name = vendor.get('name', '') + item_type = vendor.get('type', 'Vendor/Service') + category = self.get_category_display_text(vendor) + contact_person = vendor.get('contact_person', '') + phone = vendor.get('phone', '') + email = vendor.get('email', '') + website = vendor.get('website', '') + address = vendor.get('address', '') + status = vendor.get('status', 'Researching') + events = vendor.get('events', []) + event_costs = vendor.get('event_costs', {}) + event_payers = vendor.get('event_payers', {}) + total_cost = vendor.get('total_cost', vendor.get('cost', 0)) + deposit_paid = vendor.get('deposit_paid', 0) + payment_due_date = vendor.get('payment_due_date', '') + payment_method = vendor.get('payment_method', 'Not Specified') + payment_notes = vendor.get('payment_notes', '') + notes = vendor.get('notes', '') + + # Item-specific fields + quantity = vendor.get('quantity', 1) + unit_cost = vendor.get('unit_cost', 0) + + # Calculate remaining balance - use payment history for accurate total + payment_history = vendor.get('payment_history', []) + total_paid_amount = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit') + total_credits = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit') + remaining_balance = total_cost - total_paid_amount + total_credits + + # Create a visually distinct card with better separation + with st.container(): + # Card header with background styling + st.markdown(f""" +
+

{name} - ${total_cost:,.2f}

+

+ Category: {category} | + Type: {item_type} | + Status: {status} +

+
+ """, unsafe_allow_html=True) + + # Basic Information Section + with st.expander("📋 Basic Information", expanded=False): + # Events + if events: + st.markdown(f"**Events:** {', '.join(events)}") + + # Contact Information (for vendors only) + if item_type == 'Vendor/Service': + contact_info = [] + if contact_person: + contact_info.append(f"**Contact Person:** {contact_person}") + if phone: + contact_info.append(f"**Phone:** {phone}") + if email: + contact_info.append(f"**Email:** {email}") + if website: + contact_info.append(f"**Website:** [{website}]({website})") + if address: + contact_info.append(f"**Address:** {address}") + + if contact_info: + for info in contact_info: + st.markdown(info) + + # Item-specific information + if item_type == 'Item/Purchase': + st.markdown(f"**Quantity:** {quantity}") + st.markdown(f"**Unit Cost:** ${unit_cost:.2f}") + st.markdown(f"**Total Cost:** ${total_cost:.2f}") + + # Seller contact information for items + seller_phone = vendor.get('seller_phone', '') + seller_email = vendor.get('seller_email', '') + seller_website = vendor.get('seller_website', '') + + seller_contact_info = [] + if seller_phone: + seller_contact_info.append(f"**Seller Phone:** {seller_phone}") + if seller_email: + seller_contact_info.append(f"**Seller Email:** {seller_email}") + if seller_website: + seller_contact_info.append(f"**Seller Website:** [{seller_website}]({seller_website})") + + if seller_contact_info: + st.markdown("##### Seller Contact Information") + for info in seller_contact_info: + st.markdown(f"{info}", unsafe_allow_html=True) + + # Cost and Payment Information + with st.expander("💰 Cost & Payment Information", expanded=False): + # Cost metrics in columns + cost_col1, cost_col2, cost_col3, cost_col4 = st.columns(4) + with cost_col1: + st.metric("Total Cost", f"${total_cost:,.0f}") + with cost_col2: + # Show total paid amount from payment history + st.metric("Net Amount Paid", f"${total_paid_amount:,.0f}") + with cost_col3: + st.metric("Remaining Balance", f"${remaining_balance:,.0f}") + + # Show credits if any, otherwise show payment due date + if total_credits > 0: + with cost_col4: + st.metric("Credits Received", f"${total_credits:,.0f}") + else: + with cost_col4: + # Check for upcoming unpaid installments + payment_installments = vendor.get('payment_installments', []) + next_installment_due = None + + if payment_installments and len(payment_installments) > 1: + for installment in payment_installments: + if not installment.get('paid', False): + next_installment_due = installment.get('due_date', '') + break + + # Determine what to display + if not payment_due_date and remaining_balance <= 0: + st.metric("Payment Due", "Fully Paid") + elif next_installment_due: + st.metric("Payment Due", next_installment_due) + elif payment_due_date: + st.metric("Payment Due", payment_due_date) + else: + st.metric("Payment Due", "Not Set") + + # Payment details + if payment_method and payment_method != 'Not Specified': + st.markdown(f"**Payment Method:** {payment_method}") + + # Payer information + payer = self.get_payer(vendor) + if payer: + st.markdown(f"**Payer:** {payer}") + + # Payment notes + if payment_notes: + st.markdown(f"**Payment Notes:** {payment_notes}") + + # Payment installments display + payment_installments = vendor.get('payment_installments', []) + if payment_installments and len(payment_installments) > 1: + st.markdown("#### Payment Installments") + + # Calculate installment summary + total_installment_amount = sum(inst.get('amount', 0) for inst in payment_installments) + paid_installments = sum(1 for inst in payment_installments if inst.get('paid', False)) + # Use payment history for accurate total paid amount + total_paid_amount = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit') + total_credits = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit') + + # Show installment summary + inst_col1, inst_col2, inst_col3 = st.columns(3) + with inst_col1: + st.metric("Total Installments", len(payment_installments)) + with inst_col2: + st.metric("Paid Installments", f"{paid_installments}/{len(payment_installments)}") + with inst_col3: + st.metric("Amount Paid", f"${total_paid_amount:,.0f}") + + # Show individual installments + for i, installment in enumerate(payment_installments): + amount = installment.get('amount', 0) + due_date = installment.get('due_date', '') + paid = installment.get('paid', False) + paid_date = installment.get('paid_date', '') + + # Determine status and color + if paid: + status_icon = "✅" + status_text = "Paid" + if paid_date: + status_text += f" ({paid_date})" + else: + if due_date: + try: + from datetime import datetime, date + due_date_obj = datetime.fromisoformat(due_date).date() + today = date.today() + if due_date_obj < today: + status_icon = "🔴" + status_text = "Past Due" + elif due_date_obj == today: + status_icon = "🟡" + status_text = "Due Today" + else: + status_icon = "⏳" + status_text = "Pending" + except: + status_icon = "⏳" + status_text = "Pending" + else: + status_icon = "⏳" + status_text = "Pending" + + # Display installment + col_inst1, col_inst2, col_inst3 = st.columns([2, 2, 1]) + with col_inst1: + st.markdown(f"**Installment {i+1}:** ${amount:,.0f}") + with col_inst2: + st.markdown(f"Due: {due_date if due_date else 'Not Set'}") + with col_inst3: + st.markdown(f"{status_icon} {status_text}") + + # Payment History + payment_history = vendor.get('payment_history', []) + if payment_history: + with st.expander("💳 Payment History", expanded=False): + self.render_payment_history(vendor, context="card") + + # Additional Notes + if notes: + with st.expander("📝 Notes", expanded=False): + st.markdown(notes) + + # Action Buttons Section with better styling + st.markdown(""" +
+ """, unsafe_allow_html=True) + + st.markdown("#### ⚙️ Actions") + + # Create action buttons in a clean layout + action_col1, action_col2, action_col3, action_col4, action_col5 = st.columns(5) + + with action_col1: + edit_clicked = st.button("✏️ Edit", key=f"edit_vendor_{vendor_id}", help=f"Edit {'vendor' if item_type == 'Vendor/Service' else 'item'}", use_container_width=True) + + with action_col2: + tasks_clicked = st.button("📋 Tasks", key=f"tasks_vendor_{vendor_id}", help="View/Add Tasks", use_container_width=True) + + with action_col3: + status_clicked = st.button("📊 Status", key=f"status_vendor_{vendor_id}", help="Update Status", use_container_width=True) + + with action_col4: + if item_type == "Vendor/Service": + payment_clicked = st.button("💳 Payment", key=f"payment_vendor_{vendor_id}", help="Record Payment", use_container_width=True) + track_clicked = False + else: + # For items, show both Track and Payment buttons + col4a, col4b = st.columns(2) + with col4a: + track_clicked = st.button("📦 Track", key=f"track_item_{vendor_id}", help="Track Item Status", use_container_width=True) + with col4b: + payment_clicked = st.button("💳 Payment", key=f"payment_item_{vendor_id}", help="Record Payment", use_container_width=True) + + with action_col5: + delete_clicked = st.button("🗑️ Delete", key=f"delete_vendor_{vendor_id}", help=f"Delete {'vendor' if item_type == 'Vendor/Service' else 'item'}", use_container_width=True) + + st.markdown("
", unsafe_allow_html=True) + + # Add spacing between cards + st.markdown("
", unsafe_allow_html=True) + + # Check if any modal should be shown + if st.session_state.get(f"show_delete_{vendor_id}", False): + self.show_delete_confirmation(vendor_id) + elif st.session_state.get(f"show_edit_{vendor_id}", False): + self.edit_vendor(vendor) + elif st.session_state.get(f"show_track_{vendor_id}", False): + self.track_item_status(vendor) + elif st.session_state.get(f"show_payment_{vendor_id}", False): + self.record_payment(vendor) + elif st.session_state.get(f"show_tasks_{vendor_id}", False): + self.manage_vendor_tasks(vendor) + elif st.session_state.get(f"show_status_{vendor_id}", False): + self.update_vendor_status(vendor) + else: + # Handle button clicks + if edit_clicked: + # Set session state to show edit form + st.session_state[f"show_edit_{vendor_id}"] = True + st.rerun() + elif tasks_clicked: + # Set session state to show tasks management + st.session_state[f"show_tasks_{vendor_id}"] = True + st.rerun() + elif status_clicked: + # Set session state to show status update form + st.session_state[f"show_status_{vendor_id}"] = True + st.rerun() + elif payment_clicked: + # Set session state to show payment form + st.session_state[f"show_payment_{vendor_id}"] = True + st.rerun() + elif track_clicked: + # Set session state to show track status form + st.session_state[f"show_track_{vendor_id}"] = True + st.rerun() + elif delete_clicked: + # Set session state to show delete confirmation + st.session_state[f"show_delete_{vendor_id}"] = True + st.rerun() + + def edit_vendor(self, vendor): + st.markdown(f"### Edit {vendor.get('name', '')}") + + item_type = vendor.get('type', 'Vendor/Service') + + # Event selection outside of form + config = self.config_manager.load_config() + events = config.get('wedding_events', []) + event_names = [event['name'] for event in events] + current_events = vendor.get('events', []) + + if event_names: + selected_events = st.multiselect( + "Events *", + event_names, + default=current_events, + key=f"edit_events_{vendor.get('id', '')}" + ) + else: + selected_events = [] + st.info("No events configured yet.") + + # Single cost structure for all vendors and items - cost is split equally across events + st.info("💡 **Cost Allocation:** The total cost will be split equally across all selected events.") + + # Get existing payment installments + existing_installments = vendor.get('payment_installments', []) + + # Payment installments selection outside of form + st.markdown("#### Payment Structure") + use_installments = st.checkbox( + "Use payment installments", + value=len(existing_installments) > 1, + help="Check this if payments are made in multiple installments. You can switch between single payment and installments.", + key=f"edit_use_installments_{vendor.get('id', '')}" + ) + + # Number of installments outside of form for dynamic updates + num_installments = len(existing_installments) if existing_installments else 2 + if use_installments: + num_installments = st.number_input( + "Number of Installments", + min_value=2, + max_value=10, + value=len(existing_installments) if existing_installments else 2, + step=1, + key=f"edit_num_installments_{vendor.get('id', '')}" + ) + + with st.form(f"edit_form_{vendor.get('id', '')}"): + col1, col2 = st.columns(2) + + with col1: + name = st.text_input("Name *", value=vendor.get('name', ''), key=f"edit_name_{vendor.get('id', '')}") + + # Combined category list for both vendor and item types + all_categories = [ + "Venue", "Catering", "Photography", "Videography", "Music/DJ", "Entertainment", + "Flowers", "Decor", "Attire", "Hair & Makeup", "Transportation", + "Invitations", "Cake", "Officiant", "Lodging", "Decorations", "Centerpieces", + "Favors", "Signage", "Linens", "Tableware", "Lighting", "Accessories", + "Stationery", "Gifts", "Other" + ] + + default_categories = self.get_default_categories(vendor, item_type) + category = st.multiselect("Categories", all_categories, + default=default_categories, + help="Select one or more categories that apply to this vendor/item", + key=f"edit_category_{vendor.get('id', '')}") + + if item_type == 'Vendor/Service': + contact_person = st.text_input("Contact Person", value=vendor.get('contact_person', ''), key=f"edit_contact_{vendor.get('id', '')}") + phone = st.text_input("Phone", value=vendor.get('phone', ''), key=f"edit_phone_{vendor.get('id', '')}") + else: + quantity = st.number_input("Quantity", min_value=1, value=vendor.get('quantity', 1), key=f"edit_quantity_{vendor.get('id', '')}") + unit_cost = st.number_input("Unit Cost", min_value=0.0, value=float(vendor.get('unit_cost', 0)), step=0.01, format="%.2f", key=f"edit_unit_cost_{vendor.get('id', '')}") + + # Seller contact information for items + st.markdown("#### Seller Contact Information") + col_seller1, col_seller2 = st.columns(2) + with col_seller1: + seller_phone = st.text_input("Seller Phone", value=vendor.get('seller_phone', ''), key=f"edit_seller_phone_{vendor.get('id', '')}") + seller_email = st.text_input("Seller Email", value=vendor.get('seller_email', ''), key=f"edit_seller_email_{vendor.get('id', '')}") + with col_seller2: + seller_website = st.text_input("Seller Website", value=vendor.get('seller_website', ''), key=f"edit_seller_website_{vendor.get('id', '')}") + + with col2: + if item_type == 'Vendor/Service': + email = st.text_input("Email", value=vendor.get('email', ''), key=f"edit_email_{vendor.get('id', '')}") + website = st.text_input("Website", value=vendor.get('website', ''), key=f"edit_website_{vendor.get('id', '')}") + address = st.text_area("Address", value=vendor.get('address', ''), key=f"edit_address_{vendor.get('id', '')}") + + status_options = ["Researching", "Contacted", "Pending", "Booked", "Not Needed"] if item_type == 'Vendor/Service' else ["Researching", "Ordered", "Shipped", "Delivered", "Not Needed"] + current_status = vendor.get('status', 'Researching') + status_index = status_options.index(current_status) if current_status in status_options else 0 + status = st.selectbox("Status", status_options, index=status_index, key=f"edit_status_{vendor.get('id', '')}") + + + # Cost and payment information + st.markdown("#### Cost & Payment Information") + + # Initialize event costs and payers + event_costs = {} + event_payers = {} + + if item_type == 'Item/Purchase': + # Items - calculate total cost from quantity and unit cost + total_cost = quantity * unit_cost + st.number_input("Total Cost", value=total_cost, disabled=True, help="Calculated from quantity × unit cost") + else: + # Vendors - use total cost field + total_cost = st.number_input("Total Cost", min_value=0.0, value=float(vendor.get('total_cost', 0)), step=100.0, key=f"edit_total_cost_{vendor.get('id', '')}") + + col3, col4 = st.columns(2) + with col3: + deposit_paid = st.number_input("Deposit Paid", min_value=0.0, value=float(vendor.get('deposit_paid', 0)), step=100.0, key=f"edit_deposit_{vendor.get('id', '')}") + deposit_paid_date = st.date_input("Deposit Paid Date", value=self.parse_date(vendor.get('deposit_paid_date', '')), key=f"edit_deposit_date_{vendor.get('id', '')}") + payment_method = st.selectbox("Payment Method", ["Not Specified", "Check", "Credit Card", "Bank Transfer", "Cash", "PayPal", "Venmo", "Zelle", "Reimbursement", "Credit", "Other"], + index=self.get_payment_method_index(vendor.get('payment_method', 'Not Specified')), key=f"edit_payment_method_{vendor.get('id', '')}") + + with col4: + # Only show payment due date if not using installments + if not st.session_state.get(f"edit_use_installments_{vendor.get('id', '')}", len(existing_installments) > 1): + payment_due_date = st.date_input("Payment Due Date", value=self.parse_date(vendor.get('payment_due_date', '')), key=f"edit_due_date_{vendor.get('id', '')}") + else: + payment_due_date = None + payer = st.text_input("Payer", value=self.get_payer(vendor), key=f"edit_payer_{vendor.get('id', '')}") + payment_notes = st.text_area("Payment Notes", value=vendor.get('payment_notes', ''), height=100, key=f"edit_payment_notes_{vendor.get('id', '')}") + + # Set the same payer for all events (cost is split equally across events) + for event in selected_events: + event_costs[event] = 0 # Cost is stored in total_cost, not per-event + event_payers[event] = payer + + # Payment installments section (now handled outside the form) + payment_installments = [] + if use_installments: + st.info("💡 **Payment Installments:** Set up multiple payment dates and amounts. The system will track each installment and remind you when payments are due.") + + # Show helpful message if switching from single payment + if len(existing_installments) == 1: + st.warning("⚠️ **Switching to Installments:** You're changing from a single payment to installments. The existing payment information will be used as a starting point.") + + for i in range(num_installments): + st.markdown(f"**Installment {i+1}:**") + col_inst1, col_inst2, col_inst3 = st.columns(3) + + # Get existing installment data if available + existing_installment = existing_installments[i] if i < len(existing_installments) else {} + + with col_inst1: + # Calculate default amount - use existing if available, otherwise split total cost + default_amount = existing_installment.get('amount', 0.0) + if default_amount == 0.0 and total_cost > 0: + default_amount = total_cost / num_installments + + installment_amount = st.number_input( + f"Amount {i+1}", + min_value=0.0, + value=default_amount, + step=100.0, + key=f"edit_installment_amount_{i}_{vendor.get('id', '')}" + ) + + with col_inst2: + existing_date = None + if existing_installment.get('due_date'): + try: + from datetime import datetime + existing_date = datetime.fromisoformat(existing_installment['due_date']).date() + except: + existing_date = None + elif len(existing_installments) == 1 and i == 0: + # If switching from single payment, use the existing payment due date for first installment + try: + from datetime import datetime + existing_date = datetime.fromisoformat(existing_installments[0].get('due_date', '')).date() if existing_installments[0].get('due_date') else None + except: + existing_date = None + + installment_date = st.date_input( + f"Due Date {i+1}", + value=existing_date, + key=f"edit_installment_date_{i}_{vendor.get('id', '')}" + ) + + with col_inst3: + # Determine if this installment should be marked as paid + paid_value = existing_installment.get('paid', False) + if not paid_value and len(existing_installments) == 1 and i == 0: + # If switching from single payment and it was paid, mark first installment as paid + paid_value = existing_installments[0].get('paid', False) + + installment_paid = st.checkbox( + f"Paid {i+1}", + value=paid_value, + key=f"edit_installment_paid_{i}_{vendor.get('id', '')}" + ) + + # Preserve paid_date if installment is paid and has existing paid_date + paid_date = existing_installment.get('paid_date', None) + if not paid_date and len(existing_installments) == 1 and i == 0 and installment_paid: + # If switching from single payment and it was paid, preserve the paid_date + paid_date = existing_installments[0].get('paid_date', None) + + payment_installments.append({ + 'amount': installment_amount, + 'due_date': installment_date.isoformat() if installment_date else None, + 'paid': installment_paid, + 'paid_date': paid_date + }) + else: + # Single payment structure + if len(existing_installments) > 1: + st.warning("⚠️ **Switching to Single Payment:** You're changing from installments to a single payment. The total cost and deposit information will be used.") + + payment_installments = [{ + 'amount': total_cost, + 'due_date': payment_due_date.isoformat() if payment_due_date else None, + 'paid': deposit_paid >= total_cost if total_cost > 0 else False, + 'paid_date': deposit_paid_date.isoformat() if deposit_paid_date and deposit_paid >= total_cost else None + }] + + notes = st.text_area("Notes", value=vendor.get('notes', ''), key=f"edit_notes_{vendor.get('id', '')}") + + col1, col2 = st.columns(2) + with col1: + submitted = st.form_submit_button("Update", type="primary") + with col2: + cancel_clicked = st.form_submit_button("Cancel") + + if submitted: + if name and selected_events: + # Update vendor data + vendors = self.config_manager.load_json_data('vendors.json') + for v in vendors: + if v.get('id') == vendor.get('id'): + v['name'] = name + v['categories'] = category # Store as list of categories + # Keep backward compatibility with single category field + v['category'] = category[0] if category else '' + v['status'] = status + v['events'] = selected_events + v['total_cost'] = total_cost + v['deposit_paid'] = deposit_paid + v['deposit_paid_date'] = deposit_paid_date.isoformat() if deposit_paid_date else None + v['payment_due_date'] = payment_due_date.isoformat() if payment_due_date else None + v['payment_method'] = payment_method + v['payment_notes'] = payment_notes + v['notes'] = notes + + # Update cost structure and event costs/payers for all types + v['event_costs'] = event_costs + v['event_payers'] = event_payers + v['payment_installments'] = payment_installments + # Update payment history with deposit changes + v['payment_history'] = self.update_deposit_in_payment_history(v, deposit_paid, deposit_paid_date, payment_method, payment_notes) + + # Sync paid installments to payment history + # DISABLED: This function was causing deleted payments to be recreated + # self.sync_paid_installments_to_payment_history([v]) + + if item_type == 'Vendor/Service': + v['contact_person'] = contact_person + v['phone'] = phone + v['email'] = email + v['website'] = website + v['address'] = address + else: + v['quantity'] = quantity + v['unit_cost'] = unit_cost + v['seller_phone'] = seller_phone + v['seller_email'] = seller_email + v['seller_website'] = seller_website + + break + + if self.config_manager.save_json_data('vendors.json', vendors): + st.success("Vendor/Item updated successfully!") + # Clear the edit session state + vendor_id = vendor.get('id') + if f"show_edit_{vendor_id}" in st.session_state: + del st.session_state[f"show_edit_{vendor_id}"] + st.rerun() + else: + st.error("Error saving changes") + elif not name: + st.error("Please enter a name") + elif not selected_events: + st.error("Please select at least one event") + elif cancel_clicked: + # Clear the edit session state + vendor_id = vendor.get('id') + if f"show_edit_{vendor_id}" in st.session_state: + del st.session_state[f"show_edit_{vendor_id}"] + st.rerun() + + def get_category_index(self, category, item_type): + """Get the index of a category in the appropriate list""" + if item_type == 'Vendor/Service': + categories = ["Venue", "Catering", "Photography", "Videography", "Music/DJ", "Entertainment", + "Flowers", "Decor", "Attire", "Hair & Makeup", "Transportation", + "Invitations", "Cake", "Officiant", "Lodging", "Other"] + else: + categories = ["Decorations", "Centerpieces", "Favors", "Signage", "Linens", + "Tableware", "Lighting", "Flowers", "Attire", "Accessories", + "Invitations", "Stationery", "Gifts", "Other"] + + try: + return categories.index(category) + except ValueError: + return 0 + + def get_default_categories(self, vendor, item_type): + """Get default categories for multiselect, handling both single and multiple categories""" + existing_category = vendor.get('category', '') + existing_categories = vendor.get('categories', []) + + # If categories field exists and is a list, use it + if existing_categories and isinstance(existing_categories, list): + return existing_categories + + # If category field exists and is a string, convert to list + if existing_category and isinstance(existing_category, str): + return [existing_category] + + # If category field exists and is already a list, use it + if existing_category and isinstance(existing_category, list): + return existing_category + + return [] + + def get_category_display_text(self, vendor): + """Get formatted category display text for a vendor""" + categories_list = vendor.get('categories', []) + single_category = vendor.get('category', '') + + # If categories field exists and is a list, use it + if categories_list and isinstance(categories_list, list): + return ', '.join(categories_list) + + # Fall back to single category field + return single_category if single_category else 'No Category' + + def get_payment_method_index(self, payment_method): + """Get the index of a payment method""" + methods = ["Not Specified", "Check", "Credit Card", "Bank Transfer", "Cash", "PayPal", "Venmo", "Zelle", "Reimbursement", "Credit", "Other"] + try: + return methods.index(payment_method) + except ValueError: + return 0 + + def parse_date(self, date_string): + """Parse date string to date object""" + if not date_string: + return None + try: + from datetime import datetime + return datetime.fromisoformat(date_string).date() + except: + return None + + def get_payer(self, vendor): + """Get the payer from vendor data""" + event_payers = vendor.get('event_payers', {}) + if event_payers: + return list(event_payers.values())[0] + return "" + + def render_payment_history(self, vendor, context="main"): + """Render payment history for a vendor""" + payment_history = vendor.get('payment_history', []) + + if not payment_history: + st.info("No payment history available.") + return + + st.markdown("#### 💳 Payment History") + + # Calculate summary statistics + total_payments = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit') + total_credits = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit') + + # Show summary if there are credits + if total_credits > 0: + col1, col2 = st.columns(2) + with col1: + st.metric("Total Payments", f"${total_payments:,.0f}") + with col2: + st.metric("Total Credits", f"${total_credits:,.0f}") + st.markdown("---") + + # Sort payment history by date (newest first) + sorted_history = sorted(payment_history, key=lambda x: x.get('date', ''), reverse=True) + + # Display payment history in a table format + import pandas as pd + + table_data = [] + for payment in sorted_history: + amount = payment.get('amount', 0) + date = payment.get('date', '') + method = payment.get('method', 'Not Specified') + notes = payment.get('notes', '') + installment_info = payment.get('installment_info', '') + payment_type = payment.get('type', 'payment') + + # Format date + try: + from datetime import datetime + date_obj = datetime.fromisoformat(date) + formatted_date = date_obj.strftime('%Y-%m-%d') + except: + formatted_date = date + + # Create description with payment type + description_parts = [] + if payment_type == 'deposit': + description_parts.append('💰 Deposit') + elif payment_type == 'credit': + description_parts.append('Credit/Reimbursement') + elif installment_info: + description_parts.append(installment_info) + else: + description_parts.append('💳 Payment') + + if notes: + description_parts.append(notes) + description = ' - '.join(description_parts) if description_parts else 'Payment' + + # Format amount based on payment type + if payment_type == 'credit': + formatted_amount = f"-${amount:,.0f}" + else: + formatted_amount = f"${amount:,.0f}" + + table_data.append({ + 'Date': formatted_date, + 'Amount': formatted_amount, + 'Method': method, + 'Description': description + }) + + if table_data: + df = pd.DataFrame(table_data) + st.dataframe( + df, + use_container_width=True, + hide_index=True, + column_config={ + 'Date': st.column_config.TextColumn('Date', width='small'), + 'Amount': st.column_config.TextColumn('Amount', width='small'), + 'Method': st.column_config.TextColumn('Method', width='medium'), + 'Description': st.column_config.TextColumn('Description', width='large') + } + ) + + # Add edit/delete buttons for each payment + st.markdown("#### Edit Payment Records") + for i, payment in enumerate(sorted_history): + payment_id = payment.get('id', '') + amount = payment.get('amount', 0) + date = payment.get('date', '') + method = payment.get('method', 'Not Specified') + notes = payment.get('notes', '') + payment_type = payment.get('type', 'payment') + + # Create a unique key that includes vendor ID, payment ID, context, and a hash of the payment data + import hashlib + # Create a more robust unique key using vendor ID, payment ID, context, loop index, and payment data + vendor_id = vendor.get('id', 'unknown') + # Include context and more data in the hash to ensure uniqueness + payment_data_string = f"{vendor_id}_{payment_id}_{context}_{amount}_{date}_{method}_{notes}_{payment_type}_{i}" + payment_data_hash = hashlib.md5(payment_data_string.encode()).hexdigest()[:12] + unique_key_suffix = f"{vendor_id}_{payment_id}_{context}_{i}_{payment_data_hash}" + + # Format date + try: + from datetime import datetime + date_obj = datetime.fromisoformat(date) + formatted_date = date_obj.strftime('%Y-%m-%d') + except: + formatted_date = date + + # Create description + if payment_type == 'deposit': + description = '💰 Deposit' + elif payment_type == 'credit': + description = 'Credit/Reimbursement' + else: + description = '💳 Payment' + + if notes: + description += f' - {notes}' + + # Format amount + if payment_type == 'credit': + formatted_amount = f"-${amount:,.0f}" + else: + formatted_amount = f"${amount:,.0f}" + + # Display payment info with edit button + col1, col2, col3 = st.columns([3, 1, 1]) + with col1: + st.markdown(f"**{formatted_date}** - {description} - {formatted_amount} ({method})") + with col2: + if st.button("Edit", key=f"edit_payment_{unique_key_suffix}"): + st.session_state[f"edit_payment_{payment_id}_{context}"] = True + st.rerun() + with col3: + if st.button("Delete", key=f"delete_payment_{unique_key_suffix}"): + st.session_state[f"delete_payment_{payment_id}_{context}"] = True + st.rerun() + + # Handle edit action + if st.session_state.get(f"edit_payment_{payment_id}_{context}", False): + self.edit_payment_record(vendor, payment_id, context) + + # Handle delete action + if st.session_state.get(f"delete_payment_{payment_id}_{context}", False): + st.warning(f"Are you sure you want to delete this payment record?") + col1, col2 = st.columns(2) + with col1: + if st.button("Yes, Delete", key=f"confirm_delete_payment_{unique_key_suffix}"): + # Delete the payment record + vendors = self.config_manager.load_json_data('vendors.json') + for v in vendors: + if v.get('id') == vendor.get('id'): + payment_history = v.get('payment_history', []) + + # Find the payment being deleted to check if it's a credit/reimbursement + deleted_payment = None + for p in payment_history: + if p.get('id') == payment_id: + deleted_payment = p + break + + # Remove the payment record + payment_history = [p for p in payment_history if p.get('id') != payment_id] + v['payment_history'] = payment_history + + # Recalculate deposit_paid based on updated payment history + total_payments = sum(p.get('amount', 0) for p in payment_history if p.get('type') != 'credit') + total_credits = sum(p.get('amount', 0) for p in payment_history if p.get('type') == 'credit') + v['deposit_paid'] = total_payments - total_credits + + # If deleted payment was a credit/reimbursement, restore total_cost + if deleted_payment and deleted_payment.get('type') == 'credit': + v['total_cost'] = v.get('total_cost', 0) + deleted_payment.get('amount', 0) + + # If deleted payment was an installment payment, mark the corresponding installment as unpaid + if deleted_payment and deleted_payment.get('type') == 'installment': + installment_id = deleted_payment.get('installment_id') + if installment_id: + # Extract installment number from installment_id (format: "installment_1_vendor_id") + try: + installment_num = int(installment_id.split('_')[1]) - 1 # Convert to 0-based index + payment_installments = v.get('payment_installments', []) + if 0 <= installment_num < len(payment_installments): + payment_installments[installment_num]['paid'] = False + payment_installments[installment_num]['paid_date'] = None + v['payment_installments'] = payment_installments + except (ValueError, IndexError): + # If we can't parse the installment_id, skip this step + pass + + break + + if self.config_manager.save_json_data('vendors.json', vendors): + st.success("Payment record deleted successfully!") + # Clear the delete session state + if f"delete_payment_{payment_id}_{context}" in st.session_state: + del st.session_state[f"delete_payment_{payment_id}_{context}"] + st.rerun() + else: + st.error("Error deleting payment record") + with col2: + if st.button("Cancel", key=f"cancel_delete_payment_{unique_key_suffix}"): + # Clear the delete session state + if f"delete_payment_{payment_id}_{context}" in st.session_state: + del st.session_state[f"delete_payment_{payment_id}_{context}"] + st.rerun() + + # Show summary + total_paid = sum(payment.get('amount', 0) for payment in payment_history) + st.caption(f"**Total Paid:** ${total_paid:,.0f} across {len(payment_history)} payment(s)") + else: + st.info("No payment history available.") + + def manage_vendor_tasks(self, vendor): + item_type = vendor.get('type', 'Vendor/Service') + task_title = f"Tasks for {vendor.get('name', '')}" if item_type == 'Vendor/Service' else f"Tasks for {vendor.get('name', '')}" + + # Add close button + col1, col2 = st.columns([4, 1]) + with col1: + st.markdown(f"### {task_title}") + with col2: + if st.button("Close", key=f"close_tasks_{vendor.get('id', '')}"): + # Clear the tasks session state + vendor_id = vendor.get('id') + if f"show_tasks_{vendor_id}" in st.session_state: + del st.session_state[f"show_tasks_{vendor_id}"] + st.rerun() + + # Load all tasks + all_tasks = self.config_manager.load_json_data('tasks.json') + vendor_name = vendor.get('name', '') + + # Filter tasks related to this vendor + vendor_id = vendor.get('id', '') + vendor_tasks = [task for task in all_tasks if + vendor_id == task.get('vendor_id') or + vendor_name.lower() in task.get('title', '').lower() or + vendor_name.lower() in task.get('description', '').lower()] + + if vendor_tasks: + st.markdown(f"#### Current Tasks ({len(vendor_tasks)})") + for task in vendor_tasks: + self.render_vendor_task_card(task) + else: + st.info(f"No tasks currently related to {vendor_name}") + + # Add new task for this vendor/item + item_type = vendor.get('type', 'Vendor/Service') + expander_title = "Add New Task for This Vendor" if item_type == 'Vendor/Service' else "Add New Task for This Item" + with st.expander(expander_title, expanded=False): + self.render_vendor_task_form(vendor) + + def render_vendor_task_card(self, task): + task_id = task.get('id', '') + title = task.get('title', 'Untitled Task') + description = task.get('description', '') + due_date = task.get('due_date', '') + priority = task.get('priority', 'Medium') + assigned_to = task.get('assigned_to', '') + completed = task.get('completed', False) + + # Create a container for the task card + with st.container(): + # Task header with completion status and title + status_icon = "✅" if completed else "⏳" + st.markdown(f"**{status_icon} {title}**") + + # Task details in columns + col1, col2, col3, col4 = st.columns(4) + + with col1: + if due_date: + st.caption(f"📅 Due: {due_date}") + else: + st.caption("📅 No due date") + + with col2: + # Priority with color coding + if priority == "Urgent": + st.caption(f"🔴 Priority: {priority}") + elif priority == "High": + st.caption(f"🔴 Priority: {priority}") + elif priority == "Medium": + st.caption(f"🟡 Priority: {priority}") + else: + st.caption(f"🟢 Priority: {priority}") + + with col3: + # Assignment information + # Handle both old single assignee and new multiple assignees format + if isinstance(assigned_to, str): + assigned_to_display = assigned_to if assigned_to else "Unassigned" + elif isinstance(assigned_to, list): + if assigned_to: + assigned_to_display = ", ".join(assigned_to) + else: + assigned_to_display = "Unassigned" + else: + assigned_to_display = "Unassigned" + + if assigned_to_display and assigned_to_display != "Unassigned": + st.caption(f"👤 Assigned: {assigned_to_display}") + else: + st.caption("👤 Not assigned") + + with col4: + if completed: + st.caption("✅ Completed") + else: + st.caption("⏳ In Progress") + + # Description if available + if description: + st.caption(f"📝 {description}") + + # Add some spacing + st.markdown("---") + + # Action buttons below the task card + col1, col2, col3 = st.columns([1, 1, 1]) + + with col1: + if st.button("Edit", key=f"edit_vendor_task_{task_id}", help="Edit task", use_container_width=True): + st.session_state[f"editing_vendor_task_{task_id}"] = True + + with col2: + if not completed: + if st.button("Complete", key=f"complete_vendor_task_{task_id}", help="Mark complete", use_container_width=True): + self.toggle_task_completion(task_id, True) + else: + if st.button("Undo", key=f"undo_vendor_task_{task_id}", help="Mark incomplete", use_container_width=True): + self.toggle_task_completion(task_id, False) + + with col3: + if st.button("Delete", key=f"delete_vendor_task_{task_id}", help="Delete task", use_container_width=True): + self.delete_vendor_task(task_id) + + # Show edit form if editing (outside columns to span full width) + if st.session_state.get(f"editing_vendor_task_{task_id}", False): + self.render_edit_vendor_task_form(task) + + def render_vendor_task_form(self, vendor): + vendor_name = vendor.get('name', '') + vendor_category = self.get_category_display_text(vendor) + + # Load custom tags from configuration + config = self.config_manager.load_config() + custom_settings = config.get('custom_settings', {}) + custom_tags = custom_settings.get('custom_tags', []) + + with st.form(f"vendor_task_form_{vendor_name}"): + col1, col2 = st.columns(2) + + with col1: + title = st.text_input("Task Title *", placeholder="Enter task title") + description = st.text_area("Description", placeholder="Enter task description") + due_date = st.date_input("Due Date", value=None) + priority = st.selectbox("Priority", ["Low", "Medium", "High", "Urgent"]) + + with col2: + # Assigned to field with wedding party and task assignees selection + wedding_party = self.config_manager.load_json_data('wedding_party.json') + wedding_party_names = [member.get('name', '') for member in wedding_party if member.get('name')] + + # Get task assignees from config + config = self.config_manager.load_config() + custom_settings = config.get('custom_settings', {}) + task_assignees = custom_settings.get('task_assignees', []) + + # Create combined options for multiselect + all_assignee_options = [] + if wedding_party_names: + all_assignee_options.extend([f"Wedding Party: {name}" for name in wedding_party_names]) + if task_assignees: + all_assignee_options.extend([f"Task Assignee: {assignee}" for assignee in task_assignees]) + + # Multiple assignees selection + selected_assignees = st.multiselect("Assign to (select multiple people)", all_assignee_options, key=f"vendor_assignees_{vendor_name}") + + # Custom assignee text input (for additional people not in the lists) + custom_assignee = st.text_input("Additional Custom Assignee", placeholder="Enter additional assignee name (optional)", key=f"vendor_custom_assignee_{vendor_name}") + + # Combine selected assignees and custom assignee + assigned_to_list = [] + for assignee in selected_assignees: + if assignee.startswith("Wedding Party: "): + assigned_to_list.append(assignee.replace("Wedding Party: ", "")) + elif assignee.startswith("Task Assignee: "): + assigned_to_list.append(assignee.replace("Task Assignee: ", "")) + + if custom_assignee and custom_assignee.strip(): + assigned_to_list.append(custom_assignee.strip()) + + # Store as list for multiple assignees + assigned_to = assigned_to_list + + # Tags selection - use the same system as main tasks + # Pre-select relevant tags based on vendor category and type + default_tags = [] + if vendor_category in custom_tags: + default_tags.append(vendor_category) + + # Add vendor type as a tag + item_type = vendor.get('type', 'Vendor/Service') + vendor_type_tag = 'Vendor' if item_type == 'Vendor/Service' else 'Item' + if vendor_type_tag not in default_tags: + default_tags.append(vendor_type_tag) + + selected_tags = st.multiselect("Tags", custom_tags, default=default_tags) + + button_text = "Add Task" if item_type == 'Vendor/Service' else "Add Task" + + col1, col2 = st.columns(2) + with col1: + submitted = st.form_submit_button(button_text, type="primary") + with col2: + cancel_clicked = st.form_submit_button("Cancel") + + if submitted: + if title: + new_task = { + 'id': datetime.now().strftime("%Y%m%d_%H%M%S"), + 'title': title, + 'description': description, + 'due_date': due_date.isoformat() if due_date else None, + 'priority': priority, + 'group': 'Vendor & Item Management', + 'tags': selected_tags, + 'assigned_to': assigned_to, + 'vendor_id': vendor.get('id', ''), + 'completed': False, + 'created_date': datetime.now().isoformat(), + 'completed_date': None + } + + # Load existing tasks and add new one + tasks = self.config_manager.load_json_data('tasks.json') + tasks.append(new_task) + + if self.config_manager.save_json_data('tasks.json', tasks): + st.success("Task added successfully!") + st.rerun() + else: + st.error("Error saving task") + else: + st.error("Please enter a task title") + elif cancel_clicked: + st.rerun() + + def record_payment(self, vendor): + from datetime import datetime, date + + st.markdown(f"### Record Payment for {vendor.get('name', '')}") + + # Show current payment info + current_deposit = vendor.get('deposit_paid', 0) + total_cost = vendor.get('total_cost', vendor.get('cost', 0)) + # Calculate remaining balance using payment history for accuracy + payment_history = vendor.get('payment_history', []) + total_paid_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit') + total_credits_from_history = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit') + remaining_balance = total_cost - total_paid_from_history + total_credits_from_history + + # Check if vendor has payment installments + payment_installments = vendor.get('payment_installments', []) + has_installments = len(payment_installments) > 1 + + if has_installments: + # Show installment summary + total_installment_amount = sum(inst.get('amount', 0) for inst in payment_installments) + paid_installments = sum(1 for inst in payment_installments if inst.get('paid', False)) + # Use payment history for accurate total paid amount + payment_history = vendor.get('payment_history', []) + total_paid_amount = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') != 'credit') + total_credits = sum(payment.get('amount', 0) for payment in payment_history if payment.get('type') == 'credit') + + st.info(f"**Payment Installments:** {paid_installments}/{len(payment_installments)} installments paid (${total_paid_amount:,.0f} of ${total_installment_amount:,.0f})") + + # Show unpaid installments + unpaid_installments = [inst for inst in payment_installments if not inst.get('paid', False)] + if unpaid_installments: + st.markdown("#### Unpaid Installments:") + for i, installment in enumerate(unpaid_installments): + amount = installment.get('amount', 0) + due_date = installment.get('due_date', '') + + # Check if past due + if due_date: + try: + from datetime import datetime, date + due_date_obj = datetime.fromisoformat(due_date).date() + today = date.today() + if due_date_obj < today: + st.warning(f"🔴 **Installment {i+1}:** ${amount:,.0f} - **PAST DUE** (was due {due_date})") + elif due_date_obj == today: + st.warning(f"🟡 **Installment {i+1}:** ${amount:,.0f} - **DUE TODAY**") + else: + st.info(f"⏳ **Installment {i+1}:** ${amount:,.0f} - Due {due_date}") + except: + st.info(f"⏳ **Installment {i+1}:** ${amount:,.0f} - Due {due_date}") + else: + st.info(f"⏳ **Installment {i+1}:** ${amount:,.0f} - No due date set") + else: + st.info("Current Status: \$" + str(int(total_paid_from_history)) + " paid of \$" + str(int(total_cost)) + " total (\$" + str(int(remaining_balance)) + " remaining)") + + # Show payment history + payment_history = vendor.get('payment_history', []) + if payment_history: + with st.expander("💳 View Payment History", expanded=False): + self.render_payment_history(vendor, context="payment_form") + + with st.form(f"payment_form_{vendor.get('id', '')}"): + if has_installments: + # For installments, show which installment this payment is for + unpaid_installments = [inst for inst in payment_installments if not inst.get('paid', False)] + if unpaid_installments: + installment_options = [] + for i, installment in enumerate(unpaid_installments): + amount = installment.get('amount', 0) + due_date = installment.get('due_date', '') + # Find the actual installment number in the original sequence + actual_installment_num = None + for j, orig_installment in enumerate(payment_installments): + if (orig_installment.get('amount') == amount and + orig_installment.get('due_date') == installment.get('due_date')): + actual_installment_num = j + 1 + break + + installment_options.append(f"Installment {actual_installment_num}: ${amount:,.0f}" + (f" (Due: {due_date})" if due_date else "")) + + selected_installment = st.selectbox("Select Installment to Pay", installment_options) + installment_index = installment_options.index(selected_installment) + installment_amount = unpaid_installments[installment_index].get('amount', 0) + + # Pre-fill payment amount with installment amount + payment_amount = st.number_input("Payment Amount", min_value=0.0, value=installment_amount, step=100.0, help="Enter the amount paid to the vendor") + else: + st.success("All installments have been paid!") + payment_amount = st.number_input("Additional Payment Amount", min_value=0.0, value=0.0, step=100.0, help="Enter the amount paid to the vendor") + else: + payment_amount = st.number_input("Payment Amount", min_value=0.0, value=0.0, step=100.0, help="Enter the amount paid to the vendor or received as credit") + + payment_date = st.date_input("Payment Date", value=datetime.now().date()) + payment_type = st.selectbox("Payment Type", ["Payment", "Credit/Reimbursement"], help="Select 'Credit/Reimbursement' for money received back from the vendor") + payment_method = st.selectbox("Payment Method", ["Check", "Credit Card", "Bank Transfer", "Cash", "PayPal", "Venmo", "Zelle", "Reimbursement", "Credit", "Other"]) + payment_notes = st.text_area("Payment Notes", placeholder="Additional notes about this payment (e.g., check number, transaction ID, reason for credit, etc.)") + + col1, col2 = st.columns(2) + with col1: + submitted = st.form_submit_button("Record Payment", type="primary") + with col2: + cancel_clicked = st.form_submit_button("Cancel") + + if submitted: + if payment_amount > 0: + # Update vendor's payment info + vendors = self.config_manager.load_json_data('vendors.json') + for v in vendors: + if v.get('id') == vendor.get('id'): + # Determine if this is a credit/reimbursement + is_credit = payment_type == "Credit/Reimbursement" + + # Update net amount paid (subtract for credits, add for payments) + # Note: "deposit_paid" represents net amount paid (payments minus credits) + # Only update deposit_paid for credits/reimbursements, not regular payments + if is_credit: + v['deposit_paid'] = max(0, v.get('deposit_paid', 0) - payment_amount) + # Adjust total_cost down when reimbursement is added (effective cost is reduced) + v['total_cost'] = max(0, v.get('total_cost', 0) - payment_amount) + # For regular payments, don't update deposit_paid - let payment history track the total + + # Update payment method and notes + v['payment_method'] = payment_method + if payment_notes: + existing_notes = v.get('payment_notes', '') + if existing_notes: + v['payment_notes'] = f"{existing_notes}\n\n{payment_date}: {payment_notes}" + else: + v['payment_notes'] = f"{payment_date}: {payment_notes}" + + # Update installments if applicable + installment_info = None + if has_installments and unpaid_installments: + installment_index = installment_options.index(selected_installment) + installment_to_update = unpaid_installments[installment_index] + + # Find and update the installment in the vendor's installments + actual_installment_num = None + for inst in v.get('payment_installments', []): + if (inst.get('amount') == installment_to_update.get('amount') and + inst.get('due_date') == installment_to_update.get('due_date') and + not inst.get('paid', False)): + inst['paid'] = True + inst['paid_date'] = payment_date.isoformat() + # Find the actual installment number in the original sequence + for j, orig_inst in enumerate(v.get('payment_installments', [])): + if (orig_inst.get('amount') == installment_to_update.get('amount') and + orig_inst.get('due_date') == installment_to_update.get('due_date')): + actual_installment_num = j + 1 + break + installment_info = f"Installment {actual_installment_num}" + break + + # Add to payment history + payment_history = v.get('payment_history', []) + payment_record = { + 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"), + 'amount': payment_amount, + 'date': payment_date.isoformat(), + 'method': payment_method, + 'notes': payment_notes, + 'installment_info': installment_info, + 'type': 'credit' if is_credit else 'payment' + } + payment_history.append(payment_record) + v['payment_history'] = payment_history + + break + + if self.config_manager.save_json_data('vendors.json', vendors): + if is_credit: + st.success(f"Credit/Reimbursement of ${payment_amount:,.0f} recorded successfully!") + else: + st.success(f"Payment of ${payment_amount:,.0f} recorded successfully!") + # Clear the payment session state + vendor_id = vendor.get('id') + if f"show_payment_{vendor_id}" in st.session_state: + del st.session_state[f"show_payment_{vendor_id}"] + st.rerun() + else: + st.error("Error saving payment information") + else: + st.error("Please enter a payment amount greater than 0. For credits/reimbursements, enter the positive amount you received.") + elif cancel_clicked: + # Clear the payment session state + vendor_id = vendor.get('id') + if f"show_payment_{vendor_id}" in st.session_state: + del st.session_state[f"show_payment_{vendor_id}"] + st.rerun() + + def edit_payment_record(self, vendor, payment_id, context="main"): + """Edit an existing payment record""" + from datetime import datetime, date + + payment_history = vendor.get('payment_history', []) + payment_to_edit = None + + # Find the payment record to edit + for payment in payment_history: + if payment.get('id') == payment_id: + payment_to_edit = payment + break + + if not payment_to_edit: + st.error("Payment record not found") + return + + st.markdown(f"### Edit Payment Record") + + # Parse existing payment data + existing_amount = payment_to_edit.get('amount', 0) + existing_date = payment_to_edit.get('date', '') + existing_method = payment_to_edit.get('method', 'Not Specified') + existing_notes = payment_to_edit.get('notes', '') + existing_type = payment_to_edit.get('type', 'payment') + + # Parse date + try: + if existing_date: + existing_date_obj = datetime.fromisoformat(existing_date).date() + else: + existing_date_obj = date.today() + except: + existing_date_obj = date.today() + + with st.form(f"edit_payment_form_{payment_id}"): + col1, col2 = st.columns(2) + + with col1: + payment_amount = st.number_input("Payment Amount", min_value=0.0, value=float(existing_amount), step=100.0, help="Enter the amount paid to the vendor or received as credit") + payment_date = st.date_input("Payment Date", value=existing_date_obj) + payment_type = st.selectbox("Payment Type", ["Payment", "Credit/Reimbursement"], + index=1 if existing_type == 'credit' else 0, + help="Select 'Credit/Reimbursement' for money received back from the vendor") + + with col2: + payment_method = st.selectbox("Payment Method", ["Check", "Credit Card", "Bank Transfer", "Cash", "PayPal", "Venmo", "Zelle", "Reimbursement", "Credit", "Other"], + index=self.get_payment_method_index(existing_method)) + payment_notes = st.text_area("Payment Notes", value=existing_notes, placeholder="Additional notes about this payment (e.g., check number, transaction ID, reason for credit, etc.)") + + col1, col2, col3 = st.columns(3) + with col1: + save_clicked = st.form_submit_button("Save Changes", type="primary") + with col2: + cancel_clicked = st.form_submit_button("Cancel") + with col3: + delete_clicked = st.form_submit_button("Delete Payment", type="secondary") + + if save_clicked: + if payment_amount > 0: + # Update the payment record + vendors = self.config_manager.load_json_data('vendors.json') + for v in vendors: + if v.get('id') == vendor.get('id'): + payment_history = v.get('payment_history', []) + + # Find the original payment to check for type changes + original_payment = None + for payment in payment_history: + if payment.get('id') == payment_id: + original_payment = payment.copy() + break + + for payment in payment_history: + if payment.get('id') == payment_id: + # Check if payment type is changing + new_type = 'credit' if payment_type == "Credit/Reimbursement" else 'payment' + old_type = payment.get('type', 'payment') + old_amount = payment.get('amount', 0) + + # Update payment record + payment['amount'] = payment_amount + payment['date'] = payment_date.isoformat() + payment['method'] = payment_method + payment['notes'] = payment_notes + payment['type'] = new_type + + # Adjust total_cost if payment type changed + if old_type != new_type: + if new_type == 'credit' and old_type == 'payment': + # Changed from payment to credit - reduce total_cost + v['total_cost'] = max(0, v.get('total_cost', 0) - payment_amount) + elif new_type == 'payment' and old_type == 'credit': + # Changed from credit to payment - restore total_cost + v['total_cost'] = v.get('total_cost', 0) + old_amount + + # Adjust total_cost if amount changed and it's a credit + elif new_type == 'credit' and old_amount != payment_amount: + # Amount changed for credit - adjust total_cost accordingly + v['total_cost'] = max(0, v.get('total_cost', 0) - (payment_amount - old_amount)) + + break + + # Recalculate deposit_paid based on updated payment history + total_payments = sum(p.get('amount', 0) for p in payment_history if p.get('type') != 'credit') + total_credits = sum(p.get('amount', 0) for p in payment_history if p.get('type') == 'credit') + v['deposit_paid'] = total_payments - total_credits + + break + + if self.config_manager.save_json_data('vendors.json', vendors): + st.success("Payment record updated successfully!") + # Clear the edit session state + if f"edit_payment_{payment_id}_{context}" in st.session_state: + del st.session_state[f"edit_payment_{payment_id}_{context}"] + st.rerun() + else: + st.error("Error saving payment information") + else: + st.error("Please enter a payment amount greater than 0. For credits/reimbursements, enter the positive amount you received.") + + elif delete_clicked: + # Delete the payment record + vendors = self.config_manager.load_json_data('vendors.json') + for v in vendors: + if v.get('id') == vendor.get('id'): + payment_history = v.get('payment_history', []) + + # Find the payment being deleted to check if it's a credit/reimbursement + deleted_payment = None + for p in payment_history: + if p.get('id') == payment_id: + deleted_payment = p + break + + # Remove the payment record + payment_history = [p for p in payment_history if p.get('id') != payment_id] + v['payment_history'] = payment_history + + # Recalculate deposit_paid based on updated payment history + total_payments = sum(p.get('amount', 0) for p in payment_history if p.get('type') != 'credit') + total_credits = sum(p.get('amount', 0) for p in payment_history if p.get('type') == 'credit') + v['deposit_paid'] = total_payments - total_credits + + # If deleted payment was a credit/reimbursement, restore total_cost + if deleted_payment and deleted_payment.get('type') == 'credit': + v['total_cost'] = v.get('total_cost', 0) + deleted_payment.get('amount', 0) + + # If deleted payment was an installment payment, mark the corresponding installment as unpaid + if deleted_payment and deleted_payment.get('type') == 'installment': + installment_id = deleted_payment.get('installment_id') + if installment_id: + # Extract installment number from installment_id (format: "installment_1_vendor_id") + try: + installment_num = int(installment_id.split('_')[1]) - 1 # Convert to 0-based index + payment_installments = v.get('payment_installments', []) + if 0 <= installment_num < len(payment_installments): + payment_installments[installment_num]['paid'] = False + payment_installments[installment_num]['paid_date'] = None + v['payment_installments'] = payment_installments + except (ValueError, IndexError): + # If we can't parse the installment_id, skip this step + pass + + break + + if self.config_manager.save_json_data('vendors.json', vendors): + st.success("Payment record deleted successfully!") + # Clear the edit session state + if f"edit_payment_{payment_id}_{context}" in st.session_state: + del st.session_state[f"edit_payment_{payment_id}_{context}"] + st.rerun() + else: + st.error("Error deleting payment record") + + elif cancel_clicked: + # Clear the edit session state + if f"edit_payment_{payment_id}_{context}" in st.session_state: + del st.session_state[f"edit_payment_{payment_id}_{context}"] + st.rerun() + + def track_item_status(self, item): + st.markdown(f"### Track Item Status for {item.get('name', '')}") + + current_status = item.get('status', 'Researching') + + with st.form(f"status_form_{item.get('id', '')}"): + status_options = ["Researching", "Ordered", "Shipped", "Delivered", "Not Needed"] + current_index = status_options.index(current_status) if current_status in status_options else 0 + new_status = st.selectbox("Update Status", status_options, index=current_index) + status_notes = st.text_area("Status Notes", placeholder="Additional notes about this status update") + + col1, col2 = st.columns(2) + with col1: + submitted = st.form_submit_button("Update Status", type="primary") + with col2: + cancel_clicked = st.form_submit_button("Cancel") + + if submitted: + # Update item status + vendors = self.config_manager.load_json_data('vendors.json') + for v in vendors: + if v.get('id') == item.get('id'): + v['status'] = new_status + if status_notes: + v['status_notes'] = status_notes + break + + if self.config_manager.save_json_data('vendors.json', vendors): + st.success(f"Status updated to {new_status}!") + # Clear the track session state + vendor_id = item.get('id') + if f"show_track_{vendor_id}" in st.session_state: + del st.session_state[f"show_track_{vendor_id}"] + st.rerun() + else: + st.error("Error saving status update") + elif cancel_clicked: + # Clear the track session state + vendor_id = item.get('id') + if f"show_track_{vendor_id}" in st.session_state: + del st.session_state[f"show_track_{vendor_id}"] + st.rerun() + + def update_vendor_status(self, vendor): + """Update the status of a vendor or item""" + vendor_name = vendor.get('name', '') + item_type = vendor.get('type', 'Vendor/Service') + current_status = vendor.get('status', 'Researching') + + st.markdown(f"### Update Status for {vendor_name}") + + with st.form(f"status_update_form_{vendor.get('id', '')}"): + # Different status options for vendors vs items + if item_type == "Vendor/Service": + status_options = ["Researching", "Contacted", "Pending", "Booked", "Not Needed"] + else: # Item/Purchase + status_options = ["Researching", "Ordered", "Shipped", "Delivered", "Not Needed"] + + # Find current status index + current_index = status_options.index(current_status) if current_status in status_options else 0 + new_status = st.selectbox("Update Status", status_options, index=current_index) + + # Status notes + status_notes = st.text_area("Status Notes", + value=vendor.get('status_notes', ''), + placeholder="Additional notes about this status update") + + # Form buttons + col1, col2 = st.columns(2) + with col1: + submitted = st.form_submit_button("Update Status", type="primary") + with col2: + cancel_clicked = st.form_submit_button("Cancel") + + if submitted: + # Update vendor/item status + vendors = self.config_manager.load_json_data('vendors.json') + for v in vendors: + if v.get('id') == vendor.get('id'): + v['status'] = new_status + if status_notes: + v['status_notes'] = status_notes + else: + # Remove status_notes if empty + v.pop('status_notes', None) + break + + if self.config_manager.save_json_data('vendors.json', vendors): + st.success(f"Status updated to {new_status}!") + # Clear the status session state + vendor_id = vendor.get('id') + if f"show_status_{vendor_id}" in st.session_state: + del st.session_state[f"show_status_{vendor_id}"] + st.rerun() + else: + st.error("Error saving status update") + elif cancel_clicked: + # Clear the status session state + vendor_id = vendor.get('id') + if f"show_status_{vendor_id}" in st.session_state: + del st.session_state[f"show_status_{vendor_id}"] + st.rerun() + + def toggle_task_completion(self, task_id, completed): + tasks = self.config_manager.load_json_data('tasks.json') + for task in tasks: + if task.get('id') == task_id: + task['completed'] = completed + task['completed_date'] = datetime.now().isoformat() if completed else None + break + + self.config_manager.save_json_data('tasks.json', tasks) + st.rerun() + + def show_delete_confirmation(self, vendor_id): + # Find the vendor to get its name + vendors = self.config_manager.load_json_data('vendors.json') + vendor_to_delete = None + for vendor in vendors: + if vendor.get('id') == vendor_id: + vendor_to_delete = vendor + break + + if vendor_to_delete: + vendor_name = vendor_to_delete.get('name', 'Unknown') + item_type = vendor_to_delete.get('type', 'Vendor/Service') + + # Show confirmation dialog + st.warning(f"Are you sure you want to delete {vendor_name}?") + + col1, col2 = st.columns(2) + with col1: + if st.button("Yes, Delete", key=f"confirm_delete_{vendor_id}", type="primary"): + # Reload vendors to ensure we have the latest data + vendors = self.config_manager.load_json_data('vendors.json') + + # Find and remove the vendor from the list + original_count = len(vendors) + updated_vendors = [vendor for vendor in vendors if vendor.get('id') != vendor_id] + + # Check if the vendor was actually removed + if len(updated_vendors) < original_count: + # Save the updated list + if self.config_manager.save_json_data('vendors.json', updated_vendors): + st.success(f"{item_type} '{vendor_name}' deleted successfully!") + # Clear the delete confirmation state + if f"show_delete_{vendor_id}" in st.session_state: + del st.session_state[f"show_delete_{vendor_id}"] + # Force a page refresh to show updated data + st.rerun() + else: + st.error("Error saving changes to vendors file") + else: + st.error(f"Could not find vendor with ID {vendor_id} to delete") + + with col2: + if st.button("Cancel", key=f"cancel_delete_{vendor_id}"): + # Clear the delete confirmation state + if f"show_delete_{vendor_id}" in st.session_state: + del st.session_state[f"show_delete_{vendor_id}"] + st.rerun() + + def delete_vendor_task(self, task_id): + """Delete a vendor task from the tasks.json file""" + try: + # Load existing tasks + tasks = self.config_manager.load_json_data('tasks.json') + + # Find and remove the task + original_count = len(tasks) + tasks = [task for task in tasks if task.get('id') != task_id] + + if len(tasks) < original_count: + # Save the updated tasks + if self.config_manager.save_json_data('tasks.json', tasks): + st.success("Task deleted successfully!") + st.rerun() + else: + st.error("Error saving changes to tasks file") + else: + st.error("Task not found") + + except Exception as e: + st.error(f"Error deleting task: {str(e)}") + + def render_edit_vendor_task_form(self, task): + """Render the edit form for a vendor task""" + task_id = task.get('id', '') + vendor_id = task.get('vendor_id', '') + + # Get vendor information from vendor_id + vendors = self.config_manager.load_json_data('vendors.json') + vendor = next((v for v in vendors if v.get('id') == vendor_id), {}) + vendor_name = vendor.get('name', '') + vendor_category = self.get_category_display_text(vendor) + + # Load custom tags from configuration + config = self.config_manager.load_config() + custom_settings = config.get('custom_settings', {}) + custom_tags = custom_settings.get('custom_tags', []) + + with st.form(f"edit_vendor_task_form_{task_id}"): + st.markdown("### Edit Task") + + col1, col2 = st.columns(2) + + with col1: + # Pre-fill form with existing task data + title = st.text_input("Task Title *", value=task.get('title', ''), placeholder="Enter task title", key=f"edit_vendor_title_{task_id}") + description = st.text_area("Description", value=task.get('description', ''), placeholder="Enter task description", key=f"edit_vendor_description_{task_id}") + + # Handle due date + due_date_str = task.get('due_date', '') + due_date = None + if due_date_str: + try: + from datetime import datetime + due_date = datetime.strptime(due_date_str, '%Y-%m-%d').date() + except: + due_date = None + + due_date = st.date_input("Due Date", value=due_date, key=f"edit_vendor_due_date_{task_id}") + priority = st.selectbox("Priority", ["Low", "Medium", "High", "Urgent"], + index=["Low", "Medium", "High", "Urgent"].index(task.get('priority', 'Medium')), key=f"edit_vendor_priority_{task_id}") + + with col2: + # Assigned to field with multiple assignee support + wedding_party = self.config_manager.load_json_data('wedding_party.json') + wedding_party_names = [member.get('name', '') for member in wedding_party if member.get('name')] + + # Get custom task assignees from config + custom_settings = config.get('custom_settings', {}) + task_assignees = custom_settings.get('task_assignees', []) + + # Get current assigned_to value (handle both old single assignee and new multiple assignees) + current_assigned_to = task.get('assigned_to', '') + + # Handle backward compatibility - convert single assignee to list + if isinstance(current_assigned_to, str): + if current_assigned_to: + current_assignees = [current_assigned_to] + else: + current_assignees = [] + elif isinstance(current_assigned_to, list): + current_assignees = current_assigned_to + else: + current_assignees = [] + + # Create options for multiselect + assignee_options = [] + for name in wedding_party_names: + assignee_options.append(f"Wedding Party: {name}") + for name in task_assignees: + assignee_options.append(f"Task Assignee: {name}") + + # Pre-select current assignees + selected_assignees = [] + for assignee in current_assignees: + if assignee in wedding_party_names: + selected_assignees.append(f"Wedding Party: {assignee}") + elif assignee in task_assignees: + selected_assignees.append(f"Task Assignee: {assignee}") + + # Multiselect for assignees + selected_assignees = st.multiselect("Assign to (select multiple)", assignee_options, default=selected_assignees, key=f"edit_vendor_assignees_{task_id}") + + # Custom assignee text input for additional assignees + custom_assignee_text = "" + for assignee in current_assignees: + if assignee not in wedding_party_names and assignee not in task_assignees: + if custom_assignee_text: + custom_assignee_text += f", {assignee}" + else: + custom_assignee_text = assignee + + custom_assignee = st.text_input("Additional Custom Assignees", value=custom_assignee_text, placeholder="Enter additional assignee names (comma-separated)", key=f"edit_vendor_custom_assignee_{task_id}") + + # Combine selected assignees and custom assignees + assigned_to_list = [] + for assignee in selected_assignees: + if assignee.startswith("Wedding Party: "): + assigned_to_list.append(assignee.replace("Wedding Party: ", "")) + elif assignee.startswith("Task Assignee: "): + assigned_to_list.append(assignee.replace("Task Assignee: ", "")) + + # Parse custom assignees (comma-separated) + if custom_assignee and custom_assignee.strip(): + custom_list = [name.strip() for name in custom_assignee.split(',') if name.strip()] + assigned_to_list.extend(custom_list) + + # Store as list for multiple assignees + assigned_to = assigned_to_list + + # Tags selection - pre-select existing tags + existing_tags = task.get('tags', []) + selected_tags = st.multiselect("Tags", custom_tags, default=existing_tags, key=f"edit_vendor_tags_{task_id}") + + col1, col2 = st.columns(2) + with col1: + submitted = st.form_submit_button("Update Task", type="primary") + with col2: + cancel_clicked = st.form_submit_button("Cancel") + + if submitted: + if title: + # Update the task + updated_task = { + 'id': task_id, + 'title': title, + 'description': description, + 'due_date': due_date.isoformat() if due_date else None, + 'priority': priority, + 'group': 'Vendor & Item Management', + 'tags': selected_tags, + 'assigned_to': assigned_to, + 'vendor_id': vendor_id, + 'completed': task.get('completed', False), + 'created_date': task.get('created_date', ''), + 'completed_date': task.get('completed_date', None) + } + + # Load existing tasks and update + tasks = self.config_manager.load_json_data('tasks.json') + for i, t in enumerate(tasks): + if t.get('id') == task_id: + tasks[i] = updated_task + break + + if self.config_manager.save_json_data('tasks.json', tasks): + st.success("Task updated successfully!") + # Clear the editing state + if f"editing_vendor_task_{task_id}" in st.session_state: + del st.session_state[f"editing_vendor_task_{task_id}"] + st.rerun() + else: + st.error("Error saving task") + else: + st.error("Please enter a task title") + elif cancel_clicked: + # Clear the editing state + if f"editing_vendor_task_{task_id}" in st.session_state: + del st.session_state[f"editing_vendor_task_{task_id}"] + st.rerun()