| |
| import streamlit as st |
| import gspread |
| from google.oauth2.service_account import Credentials |
| import pandas as pd |
| from datetime import datetime |
| import pytz |
| import json |
| import os |
| import time |
| import io |
| from openpyxl import Workbook |
| from openpyxl.styles import Font, PatternFill, Alignment |
| from openpyxl.utils.dataframe import dataframe_to_rows |
|
|
| |
| st.set_page_config( |
| page_title="ChargeNode Data Manager", |
| page_icon="⚡", |
| layout="wide", |
| initial_sidebar_state="collapsed" |
| ) |
|
|
| |
| SCOPES = [ |
| 'https://www.googleapis.com/auth/spreadsheets', |
| 'https://www.googleapis.com/auth/drive' |
| ] |
|
|
| |
| MAX_REQUESTS_PER_MINUTE = 60 |
| REQUEST_DELAY = 1.0 |
|
|
| |
| SWEDISH_TZ = pytz.timezone('Europe/Stockholm') |
|
|
| |
| ADMIN_PASSWORD = os.environ.get('ADMIN') |
|
|
| |
| |
| READONLY_COLUMNS = [ |
| 'Lösenord', |
| 'Filter', |
| 'Områdeskod', |
| 'Account ID', |
| 'Företagsnamn', |
| 'Fastighetsnamn', |
| 'Address', |
| 'Stad', |
| 'Metod', |
| 'Max kW', |
| '# Uttag', |
| '# F IDs', |
| 'Latitud', |
| 'Longitud', |
| 'Hårdvara', |
| 'Tid dek', |
| 'Bil', |
| 'Vecka', |
| 'Indikativt datum', |
| 'Säljare', |
| 'Status', |
| 'Last update', |
| 'Editor', |
| 'KAM' |
| ] |
|
|
| |
| EDITABLE_COLUMNS = [ |
| 'Namn', |
| 'Email adress', |
| 'Telefon', |
| 'Tillgänglighet', |
| 'Access info', |
| 'Fritext meddelande', |
| 'Main Contact' |
| ] |
|
|
| |
| HIDDEN_COLUMNS = [ |
| 'Lösenord', |
| 'Filter', |
| 'Tid dek', |
| 'Bil', |
| 'Website', |
| 'Områdeskod', |
| 'Account ID', |
| 'Företagsnamn', |
| 'Latitud', |
| 'Longitud', |
| '# F IDs', |
| 'Max kW', |
| 'Länk till fysisk plats', |
| 'Uppdatera Lat, Long', |
| 'KAM' |
| ] |
|
|
| |
| |
| |
|
|
| class LogSheets: |
| """Class för att hantera loggning till Google Sheets""" |
| |
| def __init__(self, spreadsheet): |
| """ |
| Initialisera LogSheets med ett spreadsheet-objekt |
| |
| Args: |
| spreadsheet: gspread Spreadsheet objekt för "Omrade_updater" |
| """ |
| self.spreadsheet = spreadsheet |
| self.logs_sheet = None |
| self.perf_sheet = None |
| self._init_sheets() |
| |
| def _init_sheets(self): |
| """Initiera eller skapa logg-sheets""" |
| try: |
| |
| try: |
| self.logs_sheet = self.spreadsheet.worksheet("Omrade_updater_LOGS") |
| print("[MONITORING] Logs sheet hittades") |
| except gspread.exceptions.WorksheetNotFound: |
| print("[MONITORING] Skapar nytt Logs sheet...") |
| self.logs_sheet = self.spreadsheet.add_worksheet( |
| title="Omrade_updater_LOGS", |
| rows=1000, |
| cols=10 |
| ) |
| |
| headers = [ |
| 'Timestamp', |
| 'Event Type', |
| 'User ID', |
| 'Company Name', |
| 'Details', |
| 'Row Number', |
| 'Column', |
| 'Old Value', |
| 'New Value', |
| 'Status' |
| ] |
| self.logs_sheet.append_row(headers) |
| print("[MONITORING] Logs sheet skapat med headers") |
| |
| |
| try: |
| self.perf_sheet = self.spreadsheet.worksheet("Performance_Log") |
| print("[MONITORING] Performance sheet hittades") |
| except gspread.exceptions.WorksheetNotFound: |
| print("[MONITORING] Skapar nytt Performance sheet...") |
| self.perf_sheet = self.spreadsheet.add_worksheet( |
| title="Performance_Log", |
| rows=1000, |
| cols=6 |
| ) |
| |
| headers = [ |
| 'Timestamp', |
| 'Operation', |
| 'Duration (s)', |
| 'User ID', |
| 'Details', |
| 'Status' |
| ] |
| self.perf_sheet.append_row(headers) |
| print("[MONITORING] Performance sheet skapat med headers") |
| |
| except Exception as e: |
| print(f"[MONITORING ERROR] Kunde inte initiera sheets: {e}") |
| self.logs_sheet = None |
| self.perf_sheet = None |
| |
| def log_activity(self, event_type, user_id, company_name="", details="", |
| row_number="", column="", old_value="", new_value="", status="SUCCESS"): |
| """ |
| Logga en aktivitet till LOGS sheet |
| |
| Args: |
| event_type: Typ av händelse (LOGIN, LOGOUT, EDIT, DOWNLOAD, UPLOAD) |
| user_id: Användarens ID (Account ID) |
| company_name: Företagsnamn |
| details: Detaljer om händelsen |
| row_number: Radnummer (för editeringar) |
| column: Kolumnnamn (för editeringar) |
| old_value: Gammalt värde (för editeringar) |
| new_value: Nytt värde (för editeringar) |
| status: Status (SUCCESS, ERROR, WARNING) |
| """ |
| if not self.logs_sheet: |
| return False |
| |
| try: |
| timestamp = datetime.now(SWEDISH_TZ).strftime('%Y-%m-%d %H:%M:%S') |
| |
| row_data = [ |
| timestamp, |
| event_type, |
| str(user_id), |
| str(company_name), |
| str(details), |
| str(row_number), |
| str(column), |
| str(old_value)[:100], |
| str(new_value)[:100], |
| status |
| ] |
| |
| self.logs_sheet.append_row(row_data) |
| print(f"[MONITORING] Loggade: {event_type} för {user_id}") |
| return True |
| |
| except Exception as e: |
| print(f"[MONITORING ERROR] Kunde inte logga aktivitet: {e}") |
| return False |
| |
| def log_performance(self, operation, duration, user_id="", details="", status="SUCCESS"): |
| """ |
| Logga performance-metrics till Performance_Log sheet |
| |
| Args: |
| operation: Operation som mättes (LOAD_DATA, SAVE_CHANGES, etc.) |
| duration: Tid i sekunder |
| user_id: Användarens ID |
| details: Extra detaljer |
| status: Status |
| """ |
| if not self.perf_sheet: |
| return False |
| |
| try: |
| timestamp = datetime.now(SWEDISH_TZ).strftime('%Y-%m-%d %H:%M:%S') |
| |
| row_data = [ |
| timestamp, |
| operation, |
| f"{duration:.2f}", |
| str(user_id), |
| str(details), |
| status |
| ] |
| |
| self.perf_sheet.append_row(row_data) |
| print(f"[MONITORING] Loggade performance: {operation} ({duration:.2f}s)") |
| return True |
| |
| except Exception as e: |
| print(f"[MONITORING ERROR] Kunde inte logga performance: {e}") |
| return False |
|
|
| def safe_log_activity(log_sheets, event_type, user_id, **kwargs): |
| """ |
| Säker wrapper för loggning som aldrig kraschar appen |
| |
| Args: |
| log_sheets: LogSheets instans |
| event_type: Typ av händelse |
| user_id: Användarens ID |
| **kwargs: Extra parametrar att skicka till log_activity |
| """ |
| try: |
| if log_sheets: |
| log_sheets.log_activity(event_type, user_id, **kwargs) |
| except Exception as e: |
| print(f"[MONITORING ERROR] Safe log failed: {e}") |
| |
|
|
| def safe_log_performance(log_sheets, operation, duration, **kwargs): |
| """ |
| Säker wrapper för performance-loggning som aldrig kraschar appen |
| |
| Args: |
| log_sheets: LogSheets instans |
| operation: Operation som mättes |
| duration: Tid i sekunder |
| **kwargs: Extra parametrar att skicka till log_performance |
| """ |
| try: |
| if log_sheets: |
| log_sheets.log_performance(operation, duration, **kwargs) |
| except Exception as e: |
| print(f"[MONITORING ERROR] Safe performance log failed: {e}") |
| |
|
|
| |
| |
| |
|
|
| def show_admin_panel(log_sheets): |
| """ |
| Visa admin-panelen med lösenordsskydd och dashboard |
| |
| Args: |
| log_sheets: LogSheets instans |
| """ |
| st.title("🔐 Admin Panel") |
| |
| |
| if 'admin_authenticated' not in st.session_state: |
| st.session_state.admin_authenticated = False |
| |
| if not st.session_state.admin_authenticated: |
| |
| st.markdown("### Inloggning krävs") |
| password = st.text_input("Admin-lösenord", type="password", key="admin_password") |
| |
| if st.button("Logga in som Admin"): |
| if password == ADMIN_PASSWORD: |
| st.session_state.admin_authenticated = True |
| st.success("✅ Admin-inloggning lyckades!") |
| time.sleep(0.5) |
| st.rerun() |
| else: |
| st.error("❌ Felaktigt lösenord") |
| return |
| |
| |
| st.markdown("---") |
| |
| |
| col1, col2 = st.columns([4, 1]) |
| with col2: |
| if st.button("🚪 Logga ut"): |
| st.session_state.admin_authenticated = False |
| st.rerun() |
| |
| |
| tab1, tab2, tab3 = st.tabs(["📊 Dashboard", "📋 Aktivitetslogg", "⚡ Performance"]) |
| |
| with tab1: |
| show_dashboard(log_sheets) |
| |
| with tab2: |
| show_activity_log(log_sheets) |
| |
| with tab3: |
| show_performance_log(log_sheets) |
|
|
| def show_dashboard(log_sheets): |
| """Visa realtidsstatistik dashboard""" |
| st.markdown("### 📊 Realtidsstatistik") |
| |
| if not log_sheets or not log_sheets.logs_sheet: |
| st.warning("⚠️ Ingen loggdata tillgänglig") |
| return |
| |
| try: |
| |
| all_records = log_sheets.logs_sheet.get_all_records() |
| |
| if not all_records: |
| st.info("ℹ️ Ingen aktivitet loggad än") |
| return |
| |
| df = pd.DataFrame(all_records) |
| |
| |
| df['Timestamp'] = pd.to_datetime(df['Timestamp'], errors='coerce') |
| |
| |
| today = datetime.now(SWEDISH_TZ).date() |
| df['Date'] = df['Timestamp'].dt.date |
| |
| |
| col1, col2, col3, col4 = st.columns(4) |
| |
| with col1: |
| total_events = len(df) |
| st.metric("📝 Totala händelser", f"{total_events:,}") |
| |
| with col2: |
| today_events = len(df[df['Date'] == today]) |
| st.metric("📅 Händelser idag", f"{today_events:,}") |
| |
| with col3: |
| unique_users = df['User ID'].nunique() |
| st.metric("👥 Unika användare", f"{unique_users:,}") |
| |
| with col4: |
| total_edits = len(df[df['Event Type'] == 'EDIT']) |
| st.metric("✏️ Totala redigeringar", f"{total_edits:,}") |
| |
| st.markdown("---") |
| |
| |
| st.markdown("### 📊 Händelser per typ") |
| event_counts = df['Event Type'].value_counts() |
| st.bar_chart(event_counts) |
| |
| st.markdown("---") |
| |
| |
| st.markdown("### 📈 Aktivitet senaste 7 dagarna") |
| last_7_days = df[df['Date'] >= (today - pd.Timedelta(days=7))] |
| daily_activity = last_7_days.groupby('Date').size() |
| st.line_chart(daily_activity) |
| |
| st.markdown("---") |
| |
| |
| st.markdown("### 🏆 Mest aktiva användare") |
| user_activity = df.groupby(['User ID', 'Company Name']).size().reset_index(name='Aktiviteter') |
| user_activity = user_activity.sort_values('Aktiviteter', ascending=False).head(10) |
| st.dataframe(user_activity, use_container_width=True, hide_index=True) |
| |
| except Exception as e: |
| st.error(f"❌ Fel vid hämtning av dashboard-data: {e}") |
|
|
| def show_activity_log(log_sheets): |
| """Visa fullständig aktivitetslogg med filter""" |
| st.markdown("### 📋 Fullständig aktivitetslogg") |
| |
| if not log_sheets or not log_sheets.logs_sheet: |
| st.warning("⚠️ Ingen loggdata tillgänglig") |
| return |
| |
| try: |
| |
| all_records = log_sheets.logs_sheet.get_all_records() |
| |
| if not all_records: |
| st.info("ℹ️ Ingen aktivitet loggad än") |
| return |
| |
| df = pd.DataFrame(all_records) |
| |
| |
| col1, col2, col3 = st.columns(3) |
| |
| with col1: |
| event_types = ['Alla'] + sorted(df['Event Type'].unique().tolist()) |
| selected_event = st.selectbox("Händelsetyp", event_types) |
| |
| with col2: |
| users = ['Alla'] + sorted(df['User ID'].unique().tolist()) |
| selected_user = st.selectbox("Användare", users) |
| |
| with col3: |
| |
| date_filter = st.selectbox("Tidsperiod", |
| ["Alla", "Idag", "Senaste 7 dagarna", "Senaste 30 dagarna"]) |
| |
| |
| filtered_df = df.copy() |
| |
| if selected_event != 'Alla': |
| filtered_df = filtered_df[filtered_df['Event Type'] == selected_event] |
| |
| if selected_user != 'Alla': |
| filtered_df = filtered_df[filtered_df['User ID'] == selected_user] |
| |
| if date_filter != 'Alla': |
| filtered_df['Timestamp'] = pd.to_datetime(filtered_df['Timestamp'], errors='coerce') |
| today = datetime.now(SWEDISH_TZ) |
| |
| if date_filter == 'Idag': |
| filtered_df = filtered_df[filtered_df['Timestamp'].dt.date == today.date()] |
| elif date_filter == 'Senaste 7 dagarna': |
| filtered_df = filtered_df[filtered_df['Timestamp'] >= (today - pd.Timedelta(days=7))] |
| elif date_filter == 'Senaste 30 dagarna': |
| filtered_df = filtered_df[filtered_df['Timestamp'] >= (today - pd.Timedelta(days=30))] |
| |
| |
| st.markdown(f"**Visar {len(filtered_df)} händelser**") |
| |
| |
| filtered_df = filtered_df.sort_values('Timestamp', ascending=False) |
| |
| |
| st.dataframe( |
| filtered_df, |
| use_container_width=True, |
| hide_index=True, |
| height=600 |
| ) |
| |
| |
| if st.button("📥 Exportera till CSV"): |
| csv = filtered_df.to_csv(index=False).encode('utf-8') |
| st.download_button( |
| label="Ladda ner CSV", |
| data=csv, |
| file_name=f"activity_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", |
| mime="text/csv" |
| ) |
| |
| except Exception as e: |
| st.error(f"❌ Fel vid hämtning av loggdata: {e}") |
|
|
| def show_performance_log(log_sheets): |
| """Visa performance-metrics""" |
| st.markdown("### ⚡ Performance Metrics") |
| |
| if not log_sheets or not log_sheets.perf_sheet: |
| st.warning("⚠️ Ingen performance-data tillgänglig") |
| return |
| |
| try: |
| |
| all_records = log_sheets.perf_sheet.get_all_records() |
| |
| if not all_records: |
| st.info("ℹ️ Ingen performance-data loggad än") |
| return |
| |
| df = pd.DataFrame(all_records) |
| |
| |
| df['Duration (s)'] = pd.to_numeric(df['Duration (s)'], errors='coerce') |
| |
| |
| col1, col2, col3 = st.columns(3) |
| |
| with col1: |
| avg_duration = df['Duration (s)'].mean() |
| st.metric("⏱️ Genomsnittlig tid", f"{avg_duration:.2f}s") |
| |
| with col2: |
| max_duration = df['Duration (s)'].max() |
| st.metric("🐌 Långsammaste operation", f"{max_duration:.2f}s") |
| |
| with col3: |
| min_duration = df['Duration (s)'].min() |
| st.metric("⚡ Snabbaste operation", f"{min_duration:.2f}s") |
| |
| st.markdown("---") |
| |
| |
| st.markdown("### 📊 Genomsnittlig tid per operation") |
| avg_by_operation = df.groupby('Operation')['Duration (s)'].mean().sort_values(ascending=False) |
| st.bar_chart(avg_by_operation) |
| |
| st.markdown("---") |
| |
| |
| st.markdown("### 🕐 Senaste operationer") |
| recent_ops = df.sort_values('Timestamp', ascending=False).head(20) |
| st.dataframe(recent_ops, use_container_width=True, hide_index=True) |
| |
| except Exception as e: |
| st.error(f"❌ Fel vid hämtning av performance-data: {e}") |
|
|
| |
| |
| |
|
|
| @st.cache_resource |
| def get_google_sheet(): |
| """Anslut till Google Sheets - använder Omrade_updater""" |
| try: |
| if hasattr(st, 'secrets') and 'gcp_service_account' in st.secrets: |
| creds = Credentials.from_service_account_info( |
| st.secrets["gcp_service_account"], |
| scopes=SCOPES |
| ) |
| sheet_id = st.secrets.get("sheet_id", None) |
| elif 'GOOGLE_CREDENTIALS' in os.environ: |
| google_creds = json.loads(os.environ['GOOGLE_CREDENTIALS']) |
| creds = Credentials.from_service_account_info( |
| google_creds, |
| scopes=SCOPES |
| ) |
| sheet_id = os.environ.get('SHEET_ID', None) |
| else: |
| st.error("❌ Inga credentials hittades!") |
| return None, None, None |
| |
| client = gspread.authorize(creds) |
| |
| |
| try: |
| spreadsheet = client.open("Omrade_updater") |
| sheet = spreadsheet.sheet1 |
| |
| |
| if 'GOOGLE_CREDENTIALS' in os.environ: |
| google_creds = json.loads(os.environ['GOOGLE_CREDENTIALS']) |
| service_account_email = google_creds.get('client_email', 'Web User') |
| else: |
| service_account_email = st.secrets["gcp_service_account"].get('client_email', 'Web User') |
| |
| return sheet, service_account_email, spreadsheet |
| except gspread.exceptions.SpreadsheetNotFound: |
| st.error("❌ Kunde inte hitta sheetet 'Omrade_updater'. Kontrollera att namnet är korrekt och att service account har tillgång.") |
| return None, None, None |
| |
| except Exception as e: |
| st.error(f"❌ Kunde inte ansluta till Google Sheets: {e}") |
| return None, None, None |
|
|
| def get_sheet_data(sheet): |
| """Hämta all data från sheet med korrekt hantering av textformat""" |
| try: |
| |
| all_values = sheet.get_all_values() |
| |
| if not all_values or len(all_values) < 2: |
| st.error("❌ Sheetet är tomt eller har ingen data") |
| return None |
| |
| |
| headers = all_values[0] |
| data_rows = all_values[1:] |
| |
| |
| df = pd.DataFrame(data_rows, columns=headers) |
| |
| |
| if 'Account ID' in df.columns: |
| df['Account ID'] = df['Account ID'].astype(str) |
| if 'Områdeskod' in df.columns: |
| df['Områdeskod'] = df['Områdeskod'].astype(str) |
| |
| |
| |
| for col in df.columns: |
| if df[col].dtype == 'object': |
| df[col] = df[col].astype(str).apply( |
| lambda x: x.lstrip("'") if isinstance(x, str) and x.startswith("'") else x |
| ) |
| |
| df[col] = df[col].replace(['nan', 'None', ''], '') |
| |
| print(f"[DEBUG] Laddade {len(df)} rader från Google Sheets") |
| return df |
| except Exception as e: |
| error_str = str(e) |
| |
| |
| if "quota" in error_str.lower() or "429" in error_str: |
| st.error("❌ **API Quota överskred!**") |
| st.warning(""" |
| Google Sheets API har en gräns på antal förfrågningar per minut. |
| |
| **Vänta 1 minut** och ladda sedan om sidan (tryck F5). |
| |
| Om problemet kvarstår, kontakta migration@chargenode.eu |
| """) |
| print(f"[ERROR] QUOTA EXCEEDED: {error_str}") |
| else: |
| st.error(f"❌ Fel vid hämtning av data: {e}") |
| print(f"[ERROR] get_sheet_data fel: {e}") |
| |
| return None |
|
|
| def validate_password(df, password): |
| """Validera lösenord och returnera account ID""" |
| if 'Lösenord' not in df.columns: |
| st.error("❌ Lösenordskolumn saknas i sheetet") |
| return None |
| |
| match = df[df['Lösenord'].astype(str) == password] |
| if not match.empty: |
| account_id = match.iloc[0].get('Account ID', None) |
| return str(account_id) if account_id else None |
| return None |
|
|
| def get_company_areas(df, account_id): |
| """Hämta alla områden för ett account ID""" |
| return df[df['Account ID'] == account_id].copy() |
|
|
| def get_migration_interval(company_areas): |
| """Hämta första och sista migreringsdatum""" |
| if 'Indikativt datum' not in company_areas.columns: |
| return None, None |
| |
| dates = [] |
| for date_val in company_areas['Indikativt datum']: |
| if not date_val or str(date_val).strip() == '' or str(date_val).strip() == '0': |
| continue |
| |
| try: |
| date_str = str(date_val).strip() |
| parsed_date = None |
| |
| |
| formats_to_try = [ |
| '%Y/%m/%d', |
| '%Y-%m-%d', |
| '%d/%m/%Y', |
| '%d-%m-%Y', |
| '%Y%m%d', |
| '%d.%m.%Y', |
| '%Y.%m.%d' |
| ] |
| |
| for fmt in formats_to_try: |
| try: |
| parsed_date = datetime.strptime(date_str, fmt) |
| break |
| except ValueError: |
| continue |
| |
| if parsed_date: |
| dates.append(parsed_date) |
| except Exception as e: |
| continue |
| |
| if dates: |
| return min(dates), max(dates) |
| return None, None |
|
|
| def get_total_outlets(company_areas): |
| """Beräkna totalt antal uttag""" |
| if '# Uttag' not in company_areas.columns: |
| return 0 |
| |
| total = 0 |
| for val in company_areas['# Uttag']: |
| try: |
| if val and str(val).strip() != '': |
| total += int(float(str(val))) |
| except: |
| continue |
| return total |
|
|
| def get_hardware_summary(company_areas): |
| """Hämta sammanfattning av hårdvara""" |
| if 'Hårdvara' not in company_areas.columns: |
| return "Information saknas" |
| |
| |
| hardware_list = [] |
| for val in company_areas['Hårdvara']: |
| if val and str(val).strip() != '' and str(val).strip() != '0': |
| hardware_list.append(str(val).strip()) |
| |
| if not hardware_list: |
| return "Information saknas" |
| |
| |
| if len(set(hardware_list)) == 1: |
| return hardware_list[0] |
| |
| |
| unique_hardware = list(set(hardware_list)) |
| return ", ".join(unique_hardware) |
|
|
| def get_main_contact_name(company_areas): |
| """Hämta namnet på huvudkontakten""" |
| if 'Main Contact' not in company_areas.columns or 'Namn' not in company_areas.columns: |
| return "Ej angiven" |
| |
| |
| main_contacts = company_areas[company_areas['Main Contact'] == 'Huvudkontakt'] |
| |
| if not main_contacts.empty: |
| name = main_contacts.iloc[0]['Namn'] |
| if name and str(name).strip() != '': |
| return str(name).strip() |
| |
| return "Ej angiven" |
|
|
| def get_migration_summary(company_areas): |
| """Hämta sammanfattning av migrationsmetod""" |
| if 'Metod' not in company_areas.columns: |
| return "Information saknas" |
| |
| |
| methods = [] |
| for val in company_areas['Metod']: |
| if val and str(val).strip() != '' and str(val).strip() != '0': |
| methods.append(str(val).strip()) |
| |
| if not methods: |
| return "Information saknas" |
| |
| |
| from collections import Counter |
| method_counts = Counter(methods) |
| |
| |
| total_outlets = get_total_outlets(company_areas) |
| |
| |
| unique_cities = set() |
| if 'Stad' in company_areas.columns: |
| for val in company_areas['Stad']: |
| if val and str(val).strip() != '' and str(val).strip() != '0': |
| unique_cities.add(str(val).strip()) |
| |
| num_cities = len(unique_cities) if unique_cities else 1 |
| |
| |
| summary_parts = [] |
| for method, count in method_counts.items(): |
| summary_parts.append(f"{count}st {method} migrationer") |
| |
| methods_text = ", ".join(summary_parts) |
| |
| return f"{methods_text} om totalt {total_outlets} uttag på {num_cities} {'ort' if num_cities == 1 else 'orter'}" |
|
|
| def export_to_excel(company_areas, account_id): |
| """Exportera data till Excel med formatering, instruktioner och validering""" |
| try: |
| from openpyxl.worksheet.datavalidation import DataValidation |
| |
| |
| export_df = company_areas.drop(columns=HIDDEN_COLUMNS, errors='ignore') |
| |
| |
| output = io.BytesIO() |
| wb = Workbook() |
| |
| |
| ws = wb.active |
| ws.title = "Dina områden" |
| |
| |
| header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") |
| readonly_fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid") |
| editable_fill = PatternFill(start_color="FFFFFF", end_color="FFFFFF", fill_type="solid") |
| |
| |
| for col_idx, col_name in enumerate(export_df.columns, 1): |
| cell = ws.cell(row=1, column=col_idx, value=col_name) |
| cell.font = Font(bold=True, color="FFFFFF", size=11) |
| cell.fill = header_fill |
| cell.alignment = Alignment(horizontal="center", vertical="center") |
| |
| |
| tillganglighet_col_idx = None |
| for col_idx, col_name in enumerate(export_df.columns, 1): |
| if col_name == "Tillgänglighet": |
| tillganglighet_col_idx = col_idx |
| break |
| |
| |
| for row_idx, row in enumerate(export_df.itertuples(index=False), 2): |
| for col_idx, (col_name, value) in enumerate(zip(export_df.columns, row), 1): |
| cell = ws.cell(row=row_idx, column=col_idx, value=value) |
| cell.font = Font(size=11) |
| cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=False) |
| |
| |
| if col_name in READONLY_COLUMNS: |
| cell.fill = readonly_fill |
| else: |
| cell.fill = editable_fill |
| |
| |
| if tillganglighet_col_idx: |
| col_letter = ws.cell(row=1, column=tillganglighet_col_idx).column_letter |
| dv = DataValidation( |
| type="list", |
| formula1='"Alltid tillgängligt,Stängt - se Access info"', |
| allow_blank=True, |
| showErrorMessage=True, |
| errorTitle="Ogiltigt val", |
| error="Välj antingen 'Alltid tillgängligt' eller 'Stängt - se Access info'" |
| ) |
| dv.add(f'{col_letter}2:{col_letter}{len(export_df) + 1}') |
| ws.add_data_validation(dv) |
| |
| |
| for col_idx, col_name in enumerate(export_df.columns, 1): |
| max_length = max( |
| len(str(col_name)), |
| max([len(str(val)) for val in export_df[col_name]], default=0) |
| ) |
| ws.column_dimensions[ws.cell(row=1, column=col_idx).column_letter].width = min(max_length + 2, 50) |
| |
| |
| ws_instructions = wb.create_sheet(title="INSTRUKTIONER") |
| |
| |
| title_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") |
| warning_fill = PatternFill(start_color="FFF4E6", end_color="FFF4E6", fill_type="solid") |
| tip_fill = PatternFill(start_color="E8F5E9", end_color="E8F5E9", fill_type="solid") |
| |
| |
| ws_instructions['A1'] = "📋 INSTRUKTIONER - ChargeNode Migrering" |
| ws_instructions['A1'].font = Font(bold=True, size=16, color="FFFFFF") |
| ws_instructions['A1'].fill = title_fill |
| ws_instructions['A1'].alignment = Alignment(horizontal="center", vertical="center") |
| ws_instructions.merge_cells('A1:D1') |
| ws_instructions.row_dimensions[1].height = 30 |
| |
| |
| instructions = [ |
| ("", ""), |
| ("📝 HUR DU FYLLER I EXCEL-FILEN", ""), |
| ("", ""), |
| ("1. Editera endast de VITA fälten", "Gråa fält är låsta och kan inte ändras"), |
| ("", ""), |
| ("2. Tillgänglighet - Välj från rullistan", ""), |
| ("", "• Välj 'Alltid tillgängligt' eller 'Stängt - se Access info'"), |
| ("", ""), |
| ("3. ⚠️ VIKTIGT om Tillgänglighet", ""), |
| ("", "• Om 'Stängt - se Access info': Du MÅSTE fylla i 'Access info'"), |
| ("", " (kod, nyckel, kontaktperson, etc.)"), |
| ("", ""), |
| ("", "• Om 'Alltid tillgängligt': Ingen 'Access info' behövs - området är ju tillgängligt!"), |
| ("", ""), |
| ("4. Spara filen", "Ladda sedan upp den i systemet"), |
| ("", ""), |
| ("✅ TIPS FÖR BÄSTA RESULTAT", ""), |
| ("", ""), |
| ("• Ändra INTE kolumnordning eller kolumnnamn", ""), |
| ("• Ta INTE bort rader", ""), |
| ("• Använd dropdown-listor där de finns", ""), |
| ("• Fyll i alla obligatoriska fält", ""), |
| ("", ""), |
| ("❌ FILEN SPARAS INTE OM:", ""), |
| ("", ""), |
| ("• Ett område är markerat som 'Stängt - se Access info' utan 'Access info'", ""), |
| ("", ""), |
| ("💡 Behöver du hjälp?", "Kontakta migration@chargenode.eu"), |
| ] |
| |
| current_row = 2 |
| for text1, text2 in instructions: |
| cell_a = ws_instructions[f'A{current_row}'] |
| cell_b = ws_instructions[f'B{current_row}'] |
| |
| cell_a.value = text1 |
| cell_b.value = text2 |
| |
| |
| if text1.startswith(("📝", "✅", "❌", "💡")): |
| cell_a.font = Font(bold=True, size=13, color="1976D2") |
| ws_instructions.merge_cells(f'A{current_row}:D{current_row}') |
| elif text1.startswith("⚠️"): |
| cell_a.font = Font(bold=True, size=12, color="E65100") |
| cell_b.font = Font(size=11, color="E65100") |
| cell_a.fill = warning_fill |
| cell_b.fill = warning_fill |
| elif text1.startswith(("1.", "2.", "3.", "4.")): |
| cell_a.font = Font(bold=True, size=12) |
| cell_b.font = Font(size=11, italic=True, color="666666") |
| elif text1.startswith("•"): |
| cell_a.font = Font(size=11) |
| cell_b.font = Font(size=11, italic=True, color="666666") |
| else: |
| cell_a.font = Font(size=11) |
| cell_b.font = Font(size=11) |
| |
| cell_a.alignment = Alignment(horizontal="left", vertical="top", wrap_text=True) |
| cell_b.alignment = Alignment(horizontal="left", vertical="top", wrap_text=True) |
| |
| current_row += 1 |
| |
| |
| ws_instructions.column_dimensions['A'].width = 50 |
| ws_instructions.column_dimensions['B'].width = 60 |
| |
| wb.save(output) |
| output.seek(0) |
| return output.getvalue() |
| except Exception as e: |
| st.error(f"❌ Fel vid Excel-export: {e}") |
| return None |
|
|
| def process_uploaded_excel(uploaded_file, original_df, account_id): |
| """Bearbeta uppladdad Excel-fil och returnera ändringar""" |
| try: |
| |
| uploaded_df = pd.read_excel(uploaded_file, sheet_name='Dina områden') |
| |
| |
| original_company_data = original_df[original_df['Account ID'] == account_id].copy() |
| original_display = original_company_data.drop(columns=HIDDEN_COLUMNS, errors='ignore') |
| |
| |
| if not all(col in original_display.columns for col in uploaded_df.columns): |
| st.error("❌ Kolumnerna i Excel-filen matchar inte de förväntade kolumnerna.") |
| return None |
| |
| |
| validation_errors = [] |
| for idx in range(len(uploaded_df)): |
| tillganglighet = str(uploaded_df.iloc[idx].get('Tillgänglighet', '')).strip() |
| access_info = str(uploaded_df.iloc[idx].get('Access info', '')).strip() |
| fastighetsnamn = str(uploaded_df.iloc[idx].get('Fastighetsnamn', f'Rad {idx + 2}')) |
| |
| |
| if tillganglighet == 'Stängt - se Access info' and (access_info == '' or access_info == 'nan' or access_info == 'None'): |
| validation_errors.append(f"• **{fastighetsnamn}** (rad {idx + 2})") |
| |
| if validation_errors: |
| st.error(f""" |
| ❌ **Validering misslyckades!** |
| |
| Följande områden är markerade som **'Stängt - se Access info'** men saknar **'Access info'**: |
| |
| {chr(10).join(validation_errors)} |
| |
| **Åtgärd:** Gå tillbaka till Excel-filen och fyll i 'Access info' för dessa områden. |
| Beskriv hur vi kommer åt området (kod, nyckel, kontaktperson, etc.) |
| |
| 💡 **OBS:** Områden med 'Alltid tillgängligt' behöver ingen 'Access info' - dessa är ju tillgängliga. |
| """) |
| return None |
| |
| |
| changes = [] |
| for idx in range(min(len(uploaded_df), len(original_display))): |
| original_row_idx = original_display.index[idx] |
| |
| for col in uploaded_df.columns: |
| |
| if col not in READONLY_COLUMNS: |
| orig_val = str(original_display.iloc[idx][col]) if pd.notna(original_display.iloc[idx][col]) else "" |
| new_val = str(uploaded_df.iloc[idx][col]) if pd.notna(uploaded_df.iloc[idx][col]) else "" |
| |
| if orig_val != new_val: |
| changes.append({ |
| 'row_idx': original_row_idx, |
| 'column': col, |
| 'old_value': orig_val, |
| 'new_value': new_val |
| }) |
| |
| return changes |
| except Exception as e: |
| st.error(f"❌ Fel vid bearbetning av Excel-fil: {e}") |
| return None |
|
|
| def export_to_csv(company_areas): |
| """Exportera data till CSV med korrekt encoding""" |
| try: |
| |
| export_df = company_areas.drop(columns=HIDDEN_COLUMNS, errors='ignore') |
| |
| |
| csv_buffer = io.StringIO() |
| export_df.to_csv(csv_buffer, index=False, encoding='utf-8-sig', sep=';') |
| |
| return csv_buffer.getvalue().encode('utf-8-sig') |
| except Exception as e: |
| st.error(f"❌ Fel vid CSV-export: {e}") |
| return None |
|
|
| def test_sheet_connection(sheet): |
| """Testa att anslutningen till Google Sheets fungerar""" |
| try: |
| |
| headers = sheet.row_values(1) |
| if headers and len(headers) > 0: |
| return True, "Anslutning till Google Sheets fungerar" |
| else: |
| return False, "Kunde inte läsa data från Google Sheets" |
| except Exception as e: |
| return False, f"Anslutningsfel: {str(e)}" |
|
|
| def batch_update_cells_with_tracking(sheet, changes, df, editor_email, log_sheets=None, account_id=None): |
| """ |
| Uppdatera flera celler samtidigt med ändringsloggning och omfattande felhantering |
| |
| MONITORING: Loggar alla ändringar till Google Sheets |
| """ |
| if not changes: |
| return True, "Inga ändringar att spara" |
| |
| |
| start_time = time.time() |
| |
| |
| print(f"[DEBUG] Försöker uppdatera {len(changes)} ändringar") |
| |
| try: |
| |
| try: |
| headers = sheet.row_values(1) |
| if not headers: |
| error_msg = "❌ Kunde inte läsa headers från Google Sheets. Kontrollera din internetanslutning." |
| print(f"[ERROR] {error_msg}") |
| return False, error_msg |
| except Exception as e: |
| error_msg = f"❌ Anslutningsfel till Google Sheets: {str(e)}" |
| print(f"[ERROR] {error_msg}") |
| return False, error_msg |
| |
| |
| for change in changes: |
| col_name = change['column'] |
| if col_name not in headers: |
| error_msg = f"❌ Kolumnen '{col_name}' finns inte i Google Sheets. Kontakta migration@chargenode.eu" |
| print(f"[ERROR] {error_msg}") |
| return False, error_msg |
| |
| |
| last_update_col_idx = headers.index('Last update') + 1 if 'Last update' in headers else None |
| editor_col_idx = headers.index('Editor') + 1 if 'Editor' in headers else None |
| |
| |
| current_time = datetime.now(SWEDISH_TZ).strftime('%Y-%m-%d %H:%M:%S') |
| |
| updates = [] |
| |
| |
| for change in changes: |
| row_idx = change['row_idx'] |
| col_name = change['column'] |
| new_value = change['new_value'] |
| |
| |
| try: |
| df_position = df[df.index == row_idx].index[0] |
| sheet_row = df_position + 2 |
| except Exception as e: |
| error_msg = f"❌ Fel vid radpositionering: {str(e)}" |
| print(f"[ERROR] {error_msg}") |
| return False, error_msg |
| |
| |
| if col_name in headers: |
| col_idx = headers.index(col_name) + 1 |
| |
| |
| value_to_write = new_value |
| if col_name == 'Telefon': |
| |
| value_to_write = str(new_value) if new_value else '' |
| print(f"[DEBUG] Telefonnummer sparas som ren text: '{value_to_write}'") |
| |
| |
| updates.append({ |
| 'range': f'{gspread.utils.rowcol_to_a1(sheet_row, col_idx)}', |
| 'values': [[value_to_write]] |
| }) |
| print(f"[DEBUG] Förbereder uppdatering: {col_name} rad {sheet_row} till '{value_to_write}'") |
| |
| if not updates: |
| return False, "❌ Inga giltiga uppdateringar att utföra" |
| |
| print(f"[DEBUG] Totalt {len(updates)} uppdateringar förberedda") |
| |
| |
| max_retries = 3 |
| batch_size = 50 |
| update_successful = False |
| last_error = None |
| |
| for attempt in range(max_retries): |
| try: |
| print(f"[DEBUG] Försök {attempt + 1} av {max_retries}") |
| |
| for i in range(0, len(updates), batch_size): |
| batch = updates[i:i + batch_size] |
| print(f"[DEBUG] Uppdaterar batch {i//batch_size + 1}: {len(batch)} celler") |
| |
| sheet.batch_update(batch, value_input_option='RAW') |
| time.sleep(REQUEST_DELAY) |
| |
| |
| update_successful = True |
| print(f"[SUCCESS] Batch-uppdatering lyckades på försök {attempt + 1}") |
| break |
| |
| except gspread.exceptions.APIError as e: |
| last_error = str(e) |
| print(f"[ERROR] API-fel på försök {attempt + 1}: {last_error}") |
| if attempt < max_retries - 1: |
| wait_time = (attempt + 1) * 2 |
| print(f"[DEBUG] Väntar {wait_time} sekunder innan nästa försök...") |
| time.sleep(wait_time) |
| continue |
| else: |
| error_msg = f"❌ API-fel efter {max_retries} försök: {last_error}. Vänta en minut och försök igen." |
| return False, error_msg |
| |
| except Exception as e: |
| last_error = str(e) |
| print(f"[ERROR] Oväntat fel på försök {attempt + 1}: {last_error}") |
| if attempt < max_retries - 1: |
| time.sleep(2) |
| continue |
| else: |
| error_msg = f"❌ Oväntat fel vid uppdatering: {last_error}" |
| return False, error_msg |
| |
| |
| if not update_successful: |
| error_msg = f"❌ Uppdateringen misslyckades efter {max_retries} försök. Senaste fel: {last_error}" |
| print(f"[ERROR] {error_msg}") |
| return False, error_msg |
| |
| |
| affected_rows = set([change['row_idx'] for change in changes]) |
| tracking_updates = [] |
| |
| for row_idx in affected_rows: |
| try: |
| df_position = df[df.index == row_idx].index[0] |
| sheet_row = df_position + 2 |
| |
| if last_update_col_idx: |
| tracking_updates.append({ |
| 'range': f'{gspread.utils.rowcol_to_a1(sheet_row, last_update_col_idx)}', |
| 'values': [[current_time]] |
| }) |
| |
| if editor_col_idx: |
| tracking_updates.append({ |
| 'range': f'{gspread.utils.rowcol_to_a1(sheet_row, editor_col_idx)}', |
| 'values': [[editor_email]] |
| }) |
| except Exception as e: |
| print(f"[WARNING] Kunde inte förbereda tracking update för rad {row_idx}: {str(e)}") |
| |
| if tracking_updates: |
| try: |
| print(f"[DEBUG] Uppdaterar {len(tracking_updates)} tracking-fält") |
| for i in range(0, len(tracking_updates), batch_size): |
| batch = tracking_updates[i:i + batch_size] |
| |
| sheet.batch_update(batch, value_input_option='RAW') |
| time.sleep(REQUEST_DELAY) |
| print("[SUCCESS] Tracking-uppdatering lyckades") |
| except Exception as e: |
| |
| |
| print(f"[WARNING] Tracking-uppdatering misslyckades: {str(e)}") |
| pass |
| |
| |
| try: |
| verify_change = changes[0] |
| df_position = df[df.index == verify_change['row_idx']].index[0] |
| sheet_row = df_position + 2 |
| col_idx = headers.index(verify_change['column']) + 1 |
| |
| |
| cell_range = gspread.utils.rowcol_to_a1(sheet_row, col_idx) |
| verified_value = sheet.acell(cell_range).value |
| |
| print(f"[DEBUG] Verifiering: Förväntade '{verify_change['new_value']}', fick '{verified_value}'") |
| |
| |
| expected = str(verify_change['new_value']) if verify_change['new_value'] else '' |
| actual = str(verified_value) if verified_value else '' |
| |
| if expected != actual: |
| error_msg = f"⚠️ VARNING: Verifiering misslyckades! Värdet sparades möjligen inte korrekt. Kontakta migration@chargenode.eu" |
| print(f"[ERROR] {error_msg}") |
| return False, error_msg |
| |
| print("[SUCCESS] Verifiering lyckades - data är sparad!") |
| |
| except Exception as e: |
| print(f"[WARNING] Kunde inte verifiera uppdatering: {str(e)}") |
| |
| |
| |
| if log_sheets and account_id: |
| company_name = st.session_state.get('company_name', '') |
| for change in changes: |
| safe_log_activity( |
| log_sheets, |
| 'EDIT', |
| account_id, |
| company_name=company_name, |
| details=f"Redigerade {change['column']}", |
| row_number=str(change['row_idx']), |
| column=change['column'], |
| old_value=change['old_value'], |
| new_value=change['new_value'], |
| status='SUCCESS' |
| ) |
| |
| |
| duration = time.time() - start_time |
| if log_sheets and account_id: |
| safe_log_performance( |
| log_sheets, |
| 'BATCH_UPDATE', |
| duration, |
| user_id=account_id, |
| details=f"{len(changes)} ändringar" |
| ) |
| |
| success_msg = f"✅ {len(changes)} ändringar har verifierats och sparats i Google Sheets" |
| print(f"[SUCCESS] {success_msg}") |
| return True, success_msg |
| |
| except Exception as e: |
| error_msg = str(e) |
| print(f"[ERROR] Oväntat exception: {error_msg}") |
| |
| |
| if log_sheets and account_id: |
| safe_log_activity( |
| log_sheets, |
| 'EDIT', |
| account_id, |
| details=f"Fel vid batch update: {error_msg}", |
| status='ERROR' |
| ) |
| |
| if "quota" in error_msg.lower() or "rate" in error_msg.lower() or "429" in error_msg: |
| return False, "❌ API Quota överskred! Google Sheets API-gränsen nådd. Vänta 1 minut, ladda sedan om sidan (F5) och försök igen." |
| elif "permission" in error_msg.lower() or "access" in error_msg.lower(): |
| return False, "❌ Åtkomstfel. Kontakta migration@chargenode.eu" |
| else: |
| return False, f"❌ Fel vid sparande: {error_msg}. Kontakta migration@chargenode.eu om problemet kvarstår." |
|
|
| def get_column_config(df): |
| """Generera kolumnkonfiguration för data_editor med beskrivningar och dropdowns""" |
| config = {} |
| |
| |
| editable_descriptions = { |
| 'Namn': 'Huvudkontaktperson för detta område. Personen vi kontaktar vid frågor eller problem.', |
| 'Email adress': 'Ange individuell e-postadress till kontaktpersonen för detta område.', |
| 'Telefon': 'Telefonnummer till huvudkontaktpersonen. Format: +46XXXXXXXXX eller 07X-XXXXXXX', |
| 'Tillgänglighet': 'Välj i dropdown-menyn om detta är ett område som vi alltid kan komma åt, eller om det är stängt. Om det är stängt var vänlig fyll i fältet Access info.', |
| 'Access info': 'Om området inte alltid är tillgängligt, vänligen ange hur vi kan få tillgång. T.ex. "Nycklar finns i receptionen" eller "Ring Pelle på 07XXXXXXXX när ni är på gång".', |
| 'Fritext meddelande': 'Övrig information eller meddelanden som är viktiga för oss att veta.', |
| 'Main Contact': 'Ange om denna person är huvudansvarig för kontot. Välj "Huvudkontakt" för helst endast en person per företag.' |
| } |
| |
| |
| readonly_descriptions = { |
| 'Fastighetsnamn': 'Namnet på fastigheten där laddstationerna finns', |
| 'Address': 'Gatuadress för området', |
| 'Stad': 'Ort/stad där området finns', |
| 'Metod': 'Migrationsmetod som används', |
| '# Uttag': 'Antal ladduttag på området', |
| 'Hårdvara': 'Typ av laddstationer', |
| 'Vecka': 'Planerad vecka för migrering', |
| 'Indikativt datum': 'Planerat datum för migrering', |
| 'Status': 'Nuvarande status för området', |
| 'Last update': 'Senaste uppdatering', |
| 'Editor': 'Senaste redigerare' |
| } |
| |
| |
| for col in EDITABLE_COLUMNS: |
| if col in df.columns: |
| if col == 'Tillgänglighet': |
| |
| config[col] = st.column_config.SelectboxColumn( |
| col, |
| help=editable_descriptions.get(col, ''), |
| width="medium", |
| options=[ |
| "", |
| "Alltid tillgängligt", |
| "Stängt - se Access info" |
| ], |
| required=False |
| ) |
| elif col == 'Main Contact': |
| |
| config[col] = st.column_config.SelectboxColumn( |
| col, |
| help=editable_descriptions.get(col, ''), |
| width="medium", |
| options=[ |
| "", |
| "Huvudkontakt" |
| ], |
| required=False |
| ) |
| else: |
| |
| config[col] = st.column_config.TextColumn( |
| col, |
| help=editable_descriptions.get(col, ''), |
| width="medium", |
| required=False |
| ) |
| |
| |
| for col in READONLY_COLUMNS: |
| if col in df.columns: |
| config[col] = st.column_config.TextColumn( |
| f"🔒 {col}", |
| help=readonly_descriptions.get(col, 'Detta fält kan inte editeras'), |
| disabled=True, |
| width="medium" |
| ) |
| |
| return config |
|
|
| |
| st.markdown(""" |
| <style> |
| :root { |
| --chargenode-green: #00A651; |
| --chargenode-green-dark: #008040; |
| } |
| |
| /* Göm Streamlit header och footer */ |
| #MainMenu {visibility: hidden;} |
| footer {visibility: hidden;} |
| header {visibility: hidden;} |
| |
| /* Ta bort extra padding */ |
| .block-container { |
| padding-top: 1rem; |
| padding-bottom: 0rem; |
| } |
| |
| .excel-info-box { |
| background-color: #E3F2FD; |
| border-left: 4px solid #2196F3; |
| padding: 0.75rem; |
| margin: 0.5rem 0; |
| border-radius: 4px; |
| font-size: 0.9rem; |
| } |
| |
| .warning-box { |
| background-color: #FFF3E0; |
| border-left: 4px solid #FF9800; |
| padding: 1rem; |
| margin: 1rem 0; |
| border-radius: 4px; |
| font-size: 0.95rem; |
| } |
| |
| .info-box { |
| background-color: #F1F8F4; |
| border-left: 4px solid var(--chargenode-green); |
| padding: 1rem; |
| margin: 1rem 0; |
| border-radius: 4px; |
| font-size: 0.95rem; |
| } |
| |
| .success-box { |
| background-color: #E8F5E9; |
| border-left: 4px solid var(--chargenode-green); |
| padding: 1rem; |
| margin: 1rem 0; |
| border-radius: 4px; |
| font-size: 0.95rem; |
| color: #1B5E20; |
| } |
| |
| .error-box { |
| background-color: #FFEBEE; |
| border-left: 4px solid #D32F2F; |
| padding: 1rem; |
| margin: 1rem 0; |
| border-radius: 4px; |
| font-size: 0.95rem; |
| color: #B71C1C; |
| } |
| |
| .info-box h3, .warning-box h3 { |
| margin-top: 0; |
| color: #333; |
| font-size: 1.1rem; |
| } |
| |
| .info-box p, .warning-box p { |
| margin: 0.5rem 0; |
| line-height: 1.6; |
| } |
| |
| .main-header { |
| background: linear-gradient(135deg, var(--chargenode-green) 0%, var(--chargenode-green-dark) 100%); |
| color: white; |
| padding: 2rem; |
| border-radius: 10px; |
| margin-bottom: 2rem; |
| text-align: center; |
| box-shadow: 0 4px 6px rgba(0,166,81,0.2); |
| } |
| |
| .main-header h1 { |
| margin: 0; |
| font-size: 2.5rem; |
| color: white; |
| } |
| |
| .main-header p { |
| color: rgba(255,255,255,0.95); |
| } |
| |
| .login-container { |
| max-width: 400px; |
| margin: 2rem auto; |
| padding: 2rem; |
| background: white; |
| border-radius: 10px; |
| box-shadow: 0 4px 6px rgba(0,0,0,0.1); |
| } |
| |
| .auto-save-indicator { |
| background-color: #f0f0f0; |
| padding: 0.5rem 1rem; |
| border-radius: 4px; |
| font-size: 0.9rem; |
| color: #666; |
| } |
| |
| .auto-save-indicator.saving { |
| background-color: #FFF4E5; |
| color: #FF9800; |
| } |
| |
| .auto-save-indicator.saved { |
| background-color: #E8F5E9; |
| color: var(--chargenode-green); |
| font-weight: 600; |
| } |
| |
| /* ChargeNode GRÖNA knappar */ |
| .stButton>button { |
| border-radius: 6px; |
| font-weight: 500; |
| transition: all 0.3s ease; |
| } |
| |
| .stButton>button[kind="primary"], |
| .stButton>button[data-baseweb="button"][kind="primary"] { |
| background-color: var(--chargenode-green) !important; |
| border-color: var(--chargenode-green) !important; |
| color: white !important; |
| } |
| |
| .stButton>button[kind="primary"]:hover, |
| .stButton>button[data-baseweb="button"][kind="primary"]:hover { |
| background-color: var(--chargenode-green-dark) !important; |
| border-color: var(--chargenode-green-dark) !important; |
| transform: translateY(-1px); |
| box-shadow: 0 4px 8px rgba(0,166,81,0.3); |
| } |
| |
| .stButton>button:not([kind="primary"]) { |
| background-color: white !important; |
| border: 2px solid var(--chargenode-green) !important; |
| color: var(--chargenode-green) !important; |
| } |
| |
| .stButton>button:not([kind="primary"]):hover { |
| background-color: var(--chargenode-green) !important; |
| color: white !important; |
| transform: translateY(-1px); |
| } |
| |
| /* Download button styling */ |
| .stDownloadButton>button { |
| background-color: var(--chargenode-green) !important; |
| border-color: var(--chargenode-green) !important; |
| color: white !important; |
| border-radius: 6px; |
| font-weight: 500; |
| } |
| |
| .stDownloadButton>button:hover { |
| background-color: var(--chargenode-green-dark) !important; |
| transform: translateY(-1px); |
| box-shadow: 0 4px 8px rgba(0,166,81,0.3); |
| } |
| |
| /* File uploader styling */ |
| [data-testid="stFileUploader"] { |
| border: 2px dashed var(--chargenode-green) !important; |
| border-radius: 6px; |
| background-color: #F1F8F4; |
| } |
| |
| /* Metrics styling med ChargeNode grön accent */ |
| [data-testid="stMetric"] { |
| background-color: #F8F9FA; |
| padding: 1rem; |
| border-radius: 6px; |
| border-left: 4px solid var(--chargenode-green); |
| } |
| |
| [data-testid="stMetricValue"] { |
| color: var(--chargenode-green-dark); |
| font-weight: 700; |
| } |
| |
| /* Ta bort extra spacing och borders */ |
| hr { |
| margin: 1rem 0; |
| border: none; |
| border-top: 1px solid #E0E0E0; |
| } |
| |
| /* Expander styling */ |
| .streamlit-expanderHeader { |
| background-color: #F8F9FA; |
| border-radius: 6px; |
| border-left: 3px solid var(--chargenode-green); |
| } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| |
| if 'authenticated' not in st.session_state: |
| st.session_state.authenticated = False |
| if 'account_id' not in st.session_state: |
| st.session_state.account_id = None |
| if 'df' not in st.session_state: |
| st.session_state.df = None |
| if 'company_name' not in st.session_state: |
| st.session_state.company_name = None |
| if 'original_data' not in st.session_state: |
| st.session_state.original_data = None |
| if 'last_save_time' not in st.session_state: |
| st.session_state.last_save_time = None |
| if 'saving_in_progress' not in st.session_state: |
| st.session_state.saving_in_progress = False |
| print("[DEBUG] Initierade saving_in_progress till False (första gången)") |
| else: |
| print(f"[DEBUG] Session state laddat: saving_in_progress={st.session_state.saving_in_progress}") |
| if 'service_account_email' not in st.session_state: |
| st.session_state.service_account_email = None |
| if 'log_sheets' not in st.session_state: |
| st.session_state.log_sheets = None |
| if 'show_admin' not in st.session_state: |
| st.session_state.show_admin = False |
|
|
| def show_save_status_indicator(): |
| """Visa enkel status-indikator för auto-save""" |
| if st.session_state.saving_in_progress: |
| st.markdown(""" |
| <div class='auto-save-indicator saving'> |
| 💾 Sparar ändringar... |
| </div> |
| """, unsafe_allow_html=True) |
| elif st.session_state.last_save_time: |
| timestamp = st.session_state.last_save_time.strftime('%H:%M:%S') |
| st.markdown(f""" |
| <div class='auto-save-indicator'> |
| 💾 Auto-sparar vid ändringar <span style='font-size: 0.85em; opacity: 0.7;'>• Senast: {timestamp}</span> |
| </div> |
| """, unsafe_allow_html=True) |
| else: |
| st.markdown(""" |
| <div class='auto-save-indicator'> |
| 💾 Ändrar du något sparas det automatiskt |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| |
| |
| |
|
|
| |
| sheet, service_account_email, spreadsheet = get_google_sheet() |
|
|
| if sheet is None: |
| st.stop() |
|
|
| |
| if st.session_state.log_sheets is None and spreadsheet is not None: |
| try: |
| st.session_state.log_sheets = LogSheets(spreadsheet) |
| print("[MONITORING] LogSheets initierat") |
| except Exception as e: |
| print(f"[MONITORING ERROR] Kunde inte initiera LogSheets: {e}") |
| st.session_state.log_sheets = None |
|
|
| |
| |
| with st.sidebar: |
| st.markdown("### ⚙️ System") |
| if st.button("🔐 Admin Panel", use_container_width=True): |
| st.session_state.show_admin = True |
| st.rerun() |
|
|
| |
| if st.session_state.show_admin: |
| |
| if st.button("← Tillbaka till huvudsidan"): |
| st.session_state.show_admin = False |
| st.rerun() |
| |
| show_admin_panel(st.session_state.log_sheets) |
| st.stop() |
|
|
| |
| st.markdown(""" |
| <div class='main-header'> |
| <h1>⚡ ChargeNode Data Manager</h1> |
| <p style='margin: 0.5rem 0 0 0; font-size: 1.1rem;'>Inloggad för uppdatering av företagsdata</p> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| |
| if service_account_email and not st.session_state.service_account_email: |
| st.session_state.service_account_email = service_account_email |
|
|
| |
| if st.session_state.df is None: |
| start_time = time.time() |
| with st.spinner("⏳ Laddar data från Google Sheets..."): |
| st.session_state.df = get_sheet_data(sheet) |
| if st.session_state.df is None: |
| st.stop() |
| |
| |
| duration = time.time() - start_time |
| safe_log_performance( |
| st.session_state.log_sheets, |
| 'LOAD_DATA', |
| duration, |
| details=f"Laddade {len(st.session_state.df)} rader" |
| ) |
|
|
| df = st.session_state.df |
|
|
| |
| if not st.session_state.authenticated: |
| st.markdown("<div class='login-container'>", unsafe_allow_html=True) |
| |
| st.markdown("### 🔐 Företagsinloggning") |
| st.markdown("Ange ditt unika företagslösenord för att få tillgång till era områden.") |
| |
| password_input = st.text_input( |
| "Lösenord", |
| type="password", |
| placeholder="Ange lösenord...", |
| key="password_input" |
| ) |
| |
| login_button = st.button("🚀 Logga in", use_container_width=True, type="primary") |
| |
| st.markdown("<div style='margin-top: 1rem; text-align: center; color: #666; font-size: 0.9rem;'>Har du inte fått ditt lösenord? Kontakta oss på <a href='mailto:migration@chargenode.eu'>migration@chargenode.eu</a></div>", unsafe_allow_html=True) |
| |
| if login_button: |
| if password_input: |
| with st.spinner("🔍 Validerar lösenord..."): |
| account_id = validate_password(df, password_input) |
| |
| if account_id: |
| |
| connection_ok, connection_msg = test_sheet_connection(sheet) |
| |
| if not connection_ok: |
| st.error(f"❌ {connection_msg}") |
| st.error("Kontakta migration@chargenode.eu om problemet kvarstår.") |
| st.stop() |
| |
| st.session_state.authenticated = True |
| st.session_state.account_id = account_id |
| |
| |
| company_areas = get_company_areas(df, account_id) |
| if not company_areas.empty and 'Företagsnamn' in company_areas.columns: |
| st.session_state.company_name = company_areas.iloc[0]['Företagsnamn'] |
| |
| |
| company_areas_for_original = company_areas.drop(columns=HIDDEN_COLUMNS, errors='ignore') |
| |
| company_areas_for_original = company_areas_for_original.fillna('').astype(str) |
| st.session_state.original_data = company_areas_for_original.copy() |
| |
| |
| safe_log_activity( |
| st.session_state.log_sheets, |
| 'LOGIN', |
| account_id, |
| company_name=st.session_state.company_name, |
| details=f"Inloggning från {service_account_email}" |
| ) |
| |
| st.success(f"✅ Inloggning lyckades! {connection_msg}") |
| time.sleep(0.5) |
| st.rerun() |
| else: |
| st.error("❌ Felaktigt lösenord. Försök igen eller kontakta migration@chargenode.eu") |
| |
| |
| safe_log_activity( |
| st.session_state.log_sheets, |
| 'LOGIN', |
| 'UNKNOWN', |
| details="Misslyckat inloggningsförsök", |
| status='ERROR' |
| ) |
| else: |
| st.warning("⚠️ Vänligen ange ett lösenord") |
| |
| st.markdown("</div>", unsafe_allow_html=True) |
| |
| |
| st.markdown("<hr>", unsafe_allow_html=True) |
| st.markdown(""" |
| <div style='text-align: center; padding: 1rem;'> |
| <p style='color: #999999; font-size: 0.9rem;'> |
| 🔒 Säker anslutning • Data krypteras med TLS • |
| <a href='https://www.chargenode.se' target='_blank'>ChargeNode.se</a> |
| </p> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| else: |
| |
| account_id = st.session_state.account_id |
| company_name = st.session_state.company_name or f"Företag {account_id}" |
| service_account_email = st.session_state.service_account_email or 'Web User' |
| log_sheets = st.session_state.log_sheets |
| |
| |
| company_areas = get_company_areas(df, account_id) |
| |
| if company_areas.empty: |
| st.error(f"❌ Inga områden hittades för Account ID: {account_id}") |
| st.stop() |
| |
| |
| hardware_summary = get_hardware_summary(company_areas) |
| main_contact_name = get_main_contact_name(company_areas) |
| migration_summary = get_migration_summary(company_areas) |
| |
| st.markdown(f""" |
| ### 👋 Välkommen, **{company_name}**! |
| **Migration av:** {hardware_summary} | **Huvudansvarig för kontot:** {main_contact_name} |
| |
| **Migrationsmetod:** {migration_summary} |
| """, unsafe_allow_html=False) |
| |
| |
| st.markdown(""" |
| <div class='warning-box'> |
| <h3>⚠️ VIKTIGT - Läs innan du börjar!</h3> |
| <p><strong style='color: #FF5722;'>ALLA VITA RUTOR MÅSTE FYLLAS I!</strong></p> |
| <p>Kolumner med <strong>🔒 (gult hänglås)</strong> kan du inte ändra på.</p> |
| <p>Övriga kolumner kan du klicka i och redigera.</p> |
| <p><strong>Klicka på "SPARA ÄNDRINGAR"-knappen under tabellen</strong> när du är klar.</p> |
| </div> |
| """, unsafe_allow_html=True) |
| |
| |
| st.markdown(""" |
| <div class='info-box'> |
| <h3>📋 Vad ska fyllas i?</h3> |
| <p><strong>Namn:</strong> Huvudkontaktperson för detta område. Personen vi kontaktar vid frågor eller problem.</p> |
| <p><strong>Email adress:</strong> Ange individuell e-postadress till kontaktpersonen för detta område.</p> |
| <p><strong>Telefon:</strong> Telefonnummer till huvudkontaktpersonen. Format: +46XXXXXXXXX eller 07X-XXXXXXX</p> |
| <p><strong>Tillgänglighet:</strong> Välj i dropdown-menyn om detta är ett område som vi alltid kan komma åt, |
| eller om det är stängt. Om det är stängt var vänlig fyll i fältet Access info.</p> |
| <p><strong>Access info:</strong> Om området inte alltid är tillgängligt, vänligen ange hur vi kan få tillgång. |
| T.ex. "Nycklar finns i receptionen" eller "Ring Pelle på 07XXXXXXXX när ni är på gång".</p> |
| <p><strong>Main Contact:</strong> Ange om denna person är huvudansvarig för kontot. |
| Välj "Huvudkontakt" för helst endast en person per företag.</p> |
| </div> |
| """, unsafe_allow_html=True) |
| |
| |
| total_outlets = get_total_outlets(company_areas) |
| first_date, last_date = get_migration_interval(company_areas) |
| |
| |
| col1, col2, col3 = st.columns([2, 2.5, 1.5]) |
| |
| with col1: |
| st.metric( |
| label="📍 Totalt områden", |
| value=len(company_areas), |
| help="Antal områden kopplade till ditt företag" |
| ) |
| |
| with col2: |
| if first_date and last_date: |
| date_range = f"{first_date.strftime('%Y-%m-%d')} → {last_date.strftime('%Y-%m-%d')}" |
| else: |
| date_range = "Ej angivet" |
| |
| st.metric( |
| label="📅 Indikerat migrationsintervall", |
| value=date_range if len(date_range) < 30 else "Se nedan", |
| help="Första och sista datum för planerad migrering" |
| ) |
| |
| if first_date and last_date and len(date_range) >= 30: |
| st.markdown(f"<p style='font-size: 0.8rem; color: #666;'>Från {first_date.strftime('%Y-%m-%d')} till {last_date.strftime('%Y-%m-%d')}</p>", unsafe_allow_html=True) |
| |
| with col3: |
| st.metric( |
| label="🔌 Totalt antal uttag", |
| value=total_outlets, |
| help="Summa av alla uttag för dina områden" |
| ) |
| |
| st.markdown("<br>", unsafe_allow_html=True) |
| |
| |
| st.markdown("### 📁 Filhantering") |
| |
| |
| st.markdown(""" |
| <div class='excel-info-box'> |
| <strong>💡 Jobba i Excel:</strong> Om du hellre jobbar i Excel kan du ladda ner filen här, |
| uppdatera fälten och ladda upp filen igen. |
| </div> |
| """, unsafe_allow_html=True) |
| |
| col_download, col_upload, col_logout = st.columns([2, 2, 1]) |
| |
| with col_download: |
| |
| excel_data = export_to_excel(company_areas, account_id) |
| if excel_data: |
| |
| download_clicked = st.download_button( |
| label="📥 Ladda ner Excel", |
| data=excel_data, |
| file_name=f"chargenode_data_{account_id}_{datetime.now().strftime('%Y%m%d')}.xlsx", |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", |
| help="Ladda ner som Excel-fil för att fylla i offline", |
| use_container_width=True |
| ) |
| |
| |
| if download_clicked: |
| safe_log_activity( |
| log_sheets, |
| 'DOWNLOAD', |
| account_id, |
| company_name=company_name, |
| details=f"Laddade ner Excel med {len(company_areas)} områden" |
| ) |
| |
| with col_upload: |
| |
| uploaded_file = st.file_uploader( |
| "Ladda upp uppdaterad Excel", |
| type=['xlsx'], |
| help="Ladda upp din uppdaterade Excel-fil här", |
| label_visibility="visible" |
| ) |
| |
| with col_logout: |
| if st.button("🚪 Logga ut", type="secondary", use_container_width=True): |
| |
| safe_log_activity( |
| log_sheets, |
| 'LOGOUT', |
| account_id, |
| company_name=company_name, |
| details="Utloggning" |
| ) |
| |
| st.session_state.authenticated = False |
| st.session_state.account_id = None |
| st.session_state.df = None |
| st.session_state.company_name = None |
| st.session_state.original_data = None |
| st.session_state.last_save_time = None |
| st.session_state.saving_in_progress = False |
| st.session_state.service_account_email = None |
| st.rerun() |
| |
| |
| if uploaded_file is not None: |
| |
| file_id = f"{uploaded_file.name}_{uploaded_file.size}" |
| |
| |
| if 'last_processed_file' not in st.session_state or st.session_state.last_processed_file != file_id: |
| start_time = time.time() |
| |
| with st.spinner("⏳ Bearbetar och sparar Excel-fil..."): |
| changes = process_uploaded_excel(uploaded_file, df, account_id) |
| |
| |
| safe_log_activity( |
| log_sheets, |
| 'UPLOAD', |
| account_id, |
| company_name=company_name, |
| details=f"Laddade upp Excel med {len(changes) if changes else 0} ändringar" |
| ) |
| |
| if changes: |
| |
| st.session_state.saving_in_progress = True |
| |
| success, message = batch_update_cells_with_tracking( |
| sheet, |
| changes, |
| df, |
| service_account_email, |
| log_sheets, |
| account_id |
| ) |
| |
| if success: |
| st.success(f"✅ {len(changes)} ändringar från Excel-filen har sparats automatiskt!", icon="✅") |
| st.session_state.last_save_time = datetime.now() |
| st.session_state.saving_in_progress = False |
| st.session_state.last_processed_file = file_id |
| |
| |
| print(f"[DEBUG] Uppdaterar data lokalt efter Excel-upload...") |
| for change in changes: |
| row_idx = change['row_idx'] |
| col = change['column'] |
| new_val = change['new_value'] |
| |
| if row_idx in st.session_state.df.index: |
| st.session_state.df.at[row_idx, col] = new_val |
| |
| |
| |
| updated_company_areas = get_company_areas(st.session_state.df, account_id) |
| |
| display_data = updated_company_areas.drop(columns=HIDDEN_COLUMNS, errors='ignore') |
| |
| |
| display_data = display_data.fillna('').astype(str) |
| |
| |
| st.session_state.original_data = display_data.copy() |
| |
| print(f"[DEBUG] original_data uppdaterad med {len(st.session_state.original_data)} rader") |
| |
| time.sleep(1.5) |
| st.rerun() |
| else: |
| st.session_state.saving_in_progress = False |
| st.error(f"❌ {message}\n\nKontrollera din internetanslutning och försök igen.") |
| st.session_state.last_processed_file = None |
| elif changes is not None: |
| st.info("ℹ️ Inga ändringar hittades i Excel-filen") |
| st.session_state.last_processed_file = file_id |
| |
| |
| duration = time.time() - start_time |
| safe_log_performance( |
| log_sheets, |
| 'PROCESS_EXCEL', |
| duration, |
| user_id=account_id, |
| details=f"{len(changes) if changes else 0} ändringar" |
| ) |
| else: |
| st.success(f"✅ Ändringar från denna fil har redan sparats", icon="✅") |
| |
| st.markdown("<hr>", unsafe_allow_html=True) |
| |
| |
| with st.expander("ℹ️ Hjälp och Information", expanded=False): |
| col_help1, col_help2 = st.columns(2) |
| |
| with col_help1: |
| st.markdown("#### 🔒 Låsta fält (kan ej editeras)") |
| st.markdown("Dessa fält har **🔒 (gult hänglås)** i kolumnrubriken och kan inte ändras:") |
| visible_readonly = [col for col in READONLY_COLUMNS if col not in HIDDEN_COLUMNS] |
| for col in visible_readonly[:15]: |
| if col in company_areas.columns: |
| st.markdown(f"• 🔒 {col}") |
| if len(visible_readonly) > 15: |
| st.markdown(f"*...och {len(visible_readonly) - 15} fält till*") |
| |
| with col_help2: |
| st.markdown("#### ✏️ Editerbara fält") |
| st.markdown("Dessa fält kan du klicka i och redigera:") |
| visible_editable = [col for col in EDITABLE_COLUMNS if col not in HIDDEN_COLUMNS] |
| for col in visible_editable: |
| if col in company_areas.columns: |
| st.markdown(f"• ✏️ {col}") |
| |
| |
| st.markdown("## 📊 Dataöversikt") |
| |
| |
| st.markdown(""" |
| <div style='background-color: #FFF4E6; border-left: 5px solid #FF9800; padding: 1.2rem; margin-bottom: 1.5rem; border-radius: 5px;'> |
| <h3 style='margin-top: 0; color: #E65100;'>⚠️ VIKTIGT: Så här sparar du dina ändringar</h3> |
| <ol style='margin-bottom: 0.5rem; font-size: 1.05rem;'> |
| <li>Ändra informationen i de vita fälten</li> |
| <li><strong>Tillgänglighet:</strong> Välj 'Alltid tillgängligt' eller 'Stängt - se Access info' |
| <ul style='margin-top: 0.3rem; font-size: 0.95rem;'> |
| <li>Om <strong>Stängt - se Access info</strong>: Du MÅSTE fylla i 'Access info' (kod, nyckel, kontaktperson, etc.)</li> |
| <li>Om <strong>Alltid tillgängligt</strong>: Ingen 'Access info' behövs - området är ju tillgängligt!</li> |
| </ul> |
| </li> |
| <li>När du är klar klickar du på den stora <strong>SPARA ÄNDRINGAR</strong>-knappen under tabellen</li> |
| <li>Du ser ett grönt bekräftelsemeddelande när det är sparat ✅</li> |
| <li><strong>Rekommenderat:</strong> Ladda ner din Excel-fil efter du sparat för att säkerställa att all information finns kvar</li> |
| </ol> |
| <p style='margin: 0; margin-top: 0.8rem; font-weight: bold; color: #E65100;'> |
| 💡 Tips: Du kan ändra flera fält innan du sparar - alla ändringar sparas samtidigt! |
| </p> |
| </div> |
| """, unsafe_allow_html=True) |
| |
| st.markdown(""" |
| Kolumner med **🔒** (gult hänglås) kan inte editeras. Klicka i andra fält för att redigera. |
| |
| **Kontrollera att uppgifterna i låsta celler stämmer.** Om något verkar fel, mejla till [migration@chargenode.eu](mailto:migration@chargenode.eu) så hjälper vi dig. |
| """) |
| st.markdown("<br>", unsafe_allow_html=True) |
| |
| |
| company_areas_display = company_areas.drop(columns=HIDDEN_COLUMNS, errors='ignore') |
| |
| |
| company_areas_display = company_areas_display.fillna('').astype(str) |
| |
| |
| column_order = ['Fastighetsnamn'] + [col for col in company_areas_display.columns if col != 'Fastighetsnamn'] |
| company_areas_display = company_areas_display[column_order] |
| |
| column_config = get_column_config(company_areas_display) |
| |
| |
| changes_list = [] |
| if st.session_state.original_data is not None: |
| |
| |
| pass |
| |
| |
| st.markdown("<br>", unsafe_allow_html=True) |
| |
| |
| save_button_container_top = st.container() |
| |
| st.markdown("<br>", unsafe_allow_html=True) |
| |
| |
| table_container = st.container() |
| |
| with table_container: |
| |
| edited_data = st.data_editor( |
| company_areas_display, |
| column_config=column_config, |
| use_container_width=True, |
| num_rows="fixed", |
| hide_index=True, |
| key="data_editor", |
| height=600 |
| ) |
| |
| |
| if st.session_state.original_data is not None: |
| changes_list = [] |
| |
| for idx in range(len(st.session_state.original_data)): |
| for col in company_areas_display.columns: |
| if col not in READONLY_COLUMNS: |
| try: |
| orig_val = str(st.session_state.original_data.iloc[idx][col]) |
| edit_val = str(edited_data.iloc[idx][col]) |
| |
| |
| if orig_val in ['nan', 'None', '']: |
| orig_val = '' |
| if edit_val in ['nan', 'None', '']: |
| edit_val = '' |
| |
| if orig_val != edit_val: |
| changes_list.append({ |
| 'row_idx': st.session_state.original_data.index[idx], |
| 'column': col, |
| 'old_value': orig_val, |
| 'new_value': edit_val |
| }) |
| except Exception as e: |
| pass |
| |
| |
| validation_errors = [] |
| for idx in range(len(edited_data)): |
| tillganglighet = str(edited_data.iloc[idx].get('Tillgänglighet', '')).strip() |
| access_info = str(edited_data.iloc[idx].get('Access info', '')).strip() |
| fastighetsnamn = str(edited_data.iloc[idx].get('Fastighetsnamn', f'Rad {idx + 1}')) |
| |
| |
| if tillganglighet == 'Stängt - se Access info' and (access_info == '' or access_info == 'nan' or access_info == 'None'): |
| validation_errors.append(f"• **{fastighetsnamn}**") |
| |
| |
| with save_button_container_top: |
| |
| if validation_errors: |
| st.error(f""" |
| ❌ **Validering misslyckades!** |
| |
| Följande områden är markerade som **'Stängt - se Access info'** men saknar **'Access info'**: |
| |
| {chr(10).join(validation_errors)} |
| |
| **Åtgärd:** Fyll i 'Access info' för dessa områden. |
| Beskriv hur vi kommer åt området (kod, nyckel, kontaktperson, etc.) |
| |
| 💡 **OBS:** Områden med 'Alltid tillgängligt' behöver ingen 'Access info' - dessa är ju tillgängliga. |
| """) |
| |
| if changes_list: |
| st.warning(f"⚠️ Du har **{len(changes_list)} osparade ändring{'ar' if len(changes_list) > 1 else ''}**", icon="⚠️") |
| |
| col1, col2, col3 = st.columns([1, 1, 1]) |
| with col2: |
| |
| if validation_errors: |
| save_clicked_top = st.button("💾 SPARA ÄNDRINGAR", type="secondary", use_container_width=True, key="save_button_top_disabled", disabled=True) |
| else: |
| save_clicked_top = st.button("💾 SPARA ÄNDRINGAR", type="primary", use_container_width=True, key="save_button_top") |
| else: |
| if st.session_state.last_save_time: |
| timestamp = st.session_state.last_save_time.strftime('%H:%M:%S') |
| st.success(f"✅ Alla ändringar sparade • Senast: {timestamp}", icon="✅") |
| else: |
| st.info("💾 Gör ändringar i tabellen och klicka sedan på SPARA-knappen", icon="💡") |
| |
| col1, col2, col3 = st.columns([1, 1, 1]) |
| with col2: |
| save_clicked_top = st.button("💾 SPARA ÄNDRINGAR", type="secondary", use_container_width=True, key="save_button_top_disabled", disabled=True) |
| |
| |
| st.markdown("<br>", unsafe_allow_html=True) |
| |
| |
| if validation_errors: |
| st.error(f""" |
| ❌ **Validering misslyckades!** |
| |
| Följande områden är markerade som **'Stängt - se Access info'** men saknar **'Access info'**: |
| |
| {chr(10).join(validation_errors)} |
| |
| **Åtgärd:** Fyll i 'Access info' för dessa områden. |
| Beskriv hur vi kommer åt området (kod, nyckel, kontaktperson, etc.) |
| |
| 💡 **OBS:** Områden med 'Alltid tillgängligt' behöver ingen 'Access info' - dessa är ju tillgängliga. |
| """) |
| |
| if changes_list: |
| st.warning(f"⚠️ Du har **{len(changes_list)} osparade ändring{'ar' if len(changes_list) > 1 else ''}**", icon="⚠️") |
| |
| col1, col2, col3 = st.columns([1, 1, 1]) |
| with col2: |
| |
| if validation_errors: |
| save_clicked_bottom = st.button("💾 SPARA ÄNDRINGAR", type="secondary", use_container_width=True, key="save_button_bottom_disabled_validation", disabled=True) |
| else: |
| save_clicked_bottom = st.button("💾 SPARA ÄNDRINGAR", type="primary", use_container_width=True, key="save_button_bottom") |
| else: |
| |
| if st.session_state.last_save_time: |
| timestamp = st.session_state.last_save_time.strftime('%H:%M:%S') |
| st.success(f"✅ Alla ändringar sparade • Senast: {timestamp}", icon="✅") |
| else: |
| st.info("💾 Gör ändringar i tabellen och klicka sedan på SPARA-knappen", icon="💡") |
| |
| col1, col2, col3 = st.columns([1, 1, 1]) |
| with col2: |
| save_clicked_bottom = st.button("💾 SPARA ÄNDRINGAR", type="secondary", use_container_width=True, key="save_button_bottom_disabled", disabled=True) |
| |
| |
| if changes_list and (save_clicked_top or save_clicked_bottom): |
| |
| validation_errors = [] |
| |
| |
| for idx in range(len(edited_data)): |
| tillganglighet = str(edited_data.iloc[idx].get('Tillgänglighet', '')).strip() |
| access_info = str(edited_data.iloc[idx].get('Access info', '')).strip() |
| fastighetsnamn = str(edited_data.iloc[idx].get('Fastighetsnamn', f'Rad {idx + 1}')) |
| |
| |
| if tillganglighet == 'Stängt - se Access info' and (access_info == '' or access_info == 'nan' or access_info == 'None'): |
| validation_errors.append(f"• **{fastighetsnamn}**") |
| |
| if validation_errors: |
| st.error(f""" |
| ❌ **Validering misslyckades!** |
| |
| Följande områden är markerade som **'Stängt - se Access info'** men saknar **'Access info'**: |
| |
| {chr(10).join(validation_errors)} |
| |
| **Åtgärd:** Fyll i 'Access info' för dessa områden. |
| Beskriv hur vi kommer åt området (kod, nyckel, kontaktperson, etc.) |
| |
| 💡 **OBS:** Områden med 'Alltid tillgängligt' behöver ingen 'Access info' - dessa är ju tillgängliga. |
| """) |
| else: |
| |
| with st.spinner('💾 Sparar till Google Sheets...'): |
| success, message = batch_update_cells_with_tracking( |
| sheet, |
| changes_list, |
| df, |
| service_account_email, |
| log_sheets, |
| account_id |
| ) |
| |
| if success: |
| st.success(f"✅ {len(changes_list)} ändring{'ar' if len(changes_list) > 1 else ''} har sparats!", icon="✅") |
| st.session_state.last_save_time = datetime.now() |
| |
| |
| for change in changes_list: |
| row_idx = change['row_idx'] |
| col = change['column'] |
| new_val = change['new_value'] |
| if row_idx in st.session_state.df.index: |
| st.session_state.df.at[row_idx, col] = new_val |
| |
| |
| updated_company_areas = get_company_areas(st.session_state.df, account_id) |
| display_data = updated_company_areas.drop(columns=HIDDEN_COLUMNS, errors='ignore') |
| |
| display_data = display_data.fillna('').astype(str) |
| st.session_state.original_data = display_data.copy() |
| |
| time.sleep(1) |
| st.rerun() |
| else: |
| st.error(f"❌ {message}") |
| |
| |
| st.markdown("<hr>", unsafe_allow_html=True) |
| st.markdown(f""" |
| <div style='text-align: center; padding: 1rem;'> |
| <p style='color: #999999; font-size: 0.9rem;'> |
| 🔒 Företagsdata • |
| Inloggad som <strong style='color: #333;'>{company_name}</strong> • |
| <strong style='color: #333;'>{len(company_areas)}</strong> områden • |
| <strong style='color: #333;'>{total_outlets}</strong> uttag |
| </p> |
| </div> |
| """, unsafe_allow_html=True) |